Auto-sync .kiro/ from master (post-checkout hook)
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "7a1ca671-3974-49b1-8e83-023077e758d5", "workflowType": "requirements-first", "specType": "bugfix"}
|
||||||
99
.kiro/specs/compliance-duplicate-chart-entries/bugfix.md
Normal file
99
.kiro/specs/compliance-duplicate-chart-entries/bugfix.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Bugfix Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Multiple compliance endpoints incorrectly key their queries by `compliance_uploads.id` (or by individual upload row) instead of by `compliance_uploads.report_date`. The compliance pipeline accepts one xlsx file per vertical (e.g., NTS_AEO, SDIT_CISO, TSI), so a single calendar date typically produces several `compliance_uploads` rows. Any query, aggregation, or "pick latest" logic that treats each upload as a distinct date — instead of grouping all uploads sharing a `report_date` — produces duplicated, fragmented, or silently dropped data.
|
||||||
|
|
||||||
|
The originally reported defect (GitLab issue #12, reported by nkapur) was the "Active Findings Over Time" chart on the Compliance page showing 3 entries for 5/11 after STEAM uploaded three vertical data sets that day. Investigation found that the same root cause — keying by `upload_id` instead of `report_date` — affects `GET /trends`, `GET /waterfall` (route handler `GET /top-recurring`), `GET /category-trend`, `GET /summary`, and the `compliance_snapshots` block in `persistUpload()`. This spec covers fixes for all five.
|
||||||
|
|
||||||
|
## Bug Analysis
|
||||||
|
|
||||||
|
### Current Behavior (Defect)
|
||||||
|
|
||||||
|
#### /trends (originally reported)
|
||||||
|
|
||||||
|
1.1 WHEN multiple compliance uploads exist with the same `report_date` (due to per-vertical uploads) THEN the system returns one trend data point per upload row, producing duplicate x-axis entries on the chart
|
||||||
|
|
||||||
|
1.2 WHEN the chart renders multiple entries for the same date THEN the x-axis displays repeated date labels (e.g., three "05/11/25" entries) making the trend line misleading and unreadable
|
||||||
|
|
||||||
|
1.3 WHEN per-team counts are computed for duplicate-date uploads THEN the system counts items per individual `upload_id` rather than aggregating across all uploads sharing that date, resulting in fragmented per-team totals
|
||||||
|
|
||||||
|
#### /waterfall (route handler `GET /top-recurring`)
|
||||||
|
|
||||||
|
1.4 WHEN multiple compliance uploads exist with the same `report_date` THEN the underlying query `SELECT id, report_date, ... FROM compliance_uploads ORDER BY report_date ASC` returns one row per upload and `computeWaterfall()` emits one bar per row, producing multiple bars stacked under the same date label
|
||||||
|
|
||||||
|
1.5 WHEN `computeWaterfall()` carries `start` forward across multiple rows that share a `report_date` THEN each per-vertical row's `new_count`/`recurring_count`/`resolved_count` deltas are applied sequentially as if they were separate cycles, so the running `start` and `end` totals for that date are wrong (they reflect the last row's running balance rather than the date-level aggregate)
|
||||||
|
|
||||||
|
#### /category-trend
|
||||||
|
|
||||||
|
1.6 WHEN multiple compliance uploads exist with the same `report_date` THEN the query grouped by `cu.id, cu.report_date, category` returns one row per (upload, category) pair, producing duplicated stacked bars per date when the chart is keyed on `report_date`
|
||||||
|
|
||||||
|
1.7 WHEN per-category counts are surfaced for a date with multiple uploads THEN counts are reported per-vertical instead of aggregated across all verticals sharing that `report_date`, so no row in the response represents the full date-level category total
|
||||||
|
|
||||||
|
#### /summary
|
||||||
|
|
||||||
|
1.8 WHEN multiple uploads exist for the latest `report_date` THEN the query `WHERE vertical IS NULL ORDER BY id DESC LIMIT 1` (with fallback to `vertical = 'NTS_AEO'`) selects a single upload for that date and discards the `summary_json` of all other verticals, silently dropping their data
|
||||||
|
|
||||||
|
1.9 WHEN the summary returned by `/summary` is compared against `/trends`, `/waterfall`, or `/category-trend` for the same latest date THEN the figures do not reconcile, because `/summary` reflects one vertical's upload while the other endpoints aggregate (or duplicate) across all verticals
|
||||||
|
|
||||||
|
#### `compliance_snapshots` creation in `persistUpload()`
|
||||||
|
|
||||||
|
1.10 WHEN `persistUpload()` computes per-vertical compliance stats THEN the query filters only `WHERE team IS NOT NULL` and groups by `team`, with no filter or grouping on `vertical`, so item counts pulled from `compliance_items` are aggregated across every vertical present in the table
|
||||||
|
|
||||||
|
1.11 WHEN the resulting per-team totals are written into `compliance_snapshots` for a single vertical's upload THEN the `total_devices`, `compliant`, and `non_compliant` columns reflect cross-vertical totals rather than the snapshotted vertical, corrupting the monthly snapshot record
|
||||||
|
|
||||||
|
### Expected Behavior (Correct)
|
||||||
|
|
||||||
|
#### /trends (originally reported)
|
||||||
|
|
||||||
|
2.1 WHEN multiple compliance uploads exist with the same `report_date` THEN the system SHALL aggregate their counts (new_count, recurring_count, resolved_count, total_active) into a single trend data point per unique date
|
||||||
|
|
||||||
|
2.2 WHEN the chart renders trend data THEN each unique `report_date` SHALL appear exactly once on the x-axis regardless of how many upload records exist for that date
|
||||||
|
|
||||||
|
2.3 WHEN per-team counts are computed for a date with multiple uploads THEN the system SHALL aggregate team item counts across all uploads sharing that `report_date`, producing a single per-team total per date
|
||||||
|
|
||||||
|
#### /waterfall (route handler `GET /top-recurring`)
|
||||||
|
|
||||||
|
2.4 WHEN multiple compliance uploads exist with the same `report_date` THEN the system SHALL aggregate `new_count`, `recurring_count`, and `resolved_count` across all uploads sharing that `report_date` into a single per-date row before passing rows to `computeWaterfall()`
|
||||||
|
|
||||||
|
2.5 WHEN `computeWaterfall()` consumes the aggregated rows THEN it SHALL emit exactly one waterfall entry per unique `report_date` and the running `start`/`end` totals SHALL advance using each date's date-level aggregate deltas (not per-upload deltas)
|
||||||
|
|
||||||
|
#### /category-trend
|
||||||
|
|
||||||
|
2.6 WHEN multiple compliance uploads exist with the same `report_date` THEN the query SHALL group by `cu.report_date, category` (without `cu.id` in the GROUP BY) and `SUM`/`COUNT` items across all uploads sharing the date, producing one row per (date, category) pair
|
||||||
|
|
||||||
|
2.7 WHEN per-category counts are returned for a date with multiple uploads THEN the `count` field SHALL be the sum of items in that category across every upload for that `report_date`
|
||||||
|
|
||||||
|
#### /summary
|
||||||
|
|
||||||
|
2.8 WHEN multiple uploads exist for the latest `report_date` THEN the system SHALL either (a) merge the `summary_json` of all uploads sharing that date into a single combined summary response, or (b) return a documented, well-defined selection (e.g., a named "primary" vertical) along with metadata indicating which uploads were considered, rather than silently picking one by `ORDER BY id DESC LIMIT 1`
|
||||||
|
|
||||||
|
2.9 WHEN the response is constructed for a date with multiple uploads THEN the `upload` field SHALL identify the set of uploads that contributed to the response (or, if a single representative is returned, the response SHALL include a flag/field indicating other uploads exist for the same date that were not merged)
|
||||||
|
|
||||||
|
#### `compliance_snapshots` creation in `persistUpload()`
|
||||||
|
|
||||||
|
2.10 WHEN `persistUpload()` computes per-vertical compliance stats THEN the query SHALL filter `compliance_items` by the `vertical` of the upload being persisted (in addition to `team IS NOT NULL`) and group by `vertical, team`, so each snapshot row reflects only the items belonging to that vertical
|
||||||
|
|
||||||
|
2.11 WHEN snapshots are written into `compliance_snapshots` THEN the `total_devices`, `compliant`, and `non_compliant` values SHALL match the items belonging to the snapshotted vertical only and SHALL NOT be inflated by items from other verticals
|
||||||
|
|
||||||
|
### Unchanged Behavior (Regression Prevention)
|
||||||
|
|
||||||
|
3.1 WHEN only one compliance upload exists per `report_date` (single-file upload workflow) THEN the system SHALL CONTINUE TO return that date's counts unchanged as a single trend data point
|
||||||
|
|
||||||
|
3.2 WHEN the chart displays trend data THEN the system SHALL CONTINUE TO show all existing data fields (new_count, recurring_count, resolved_count, total_active, per-team breakdowns) with correct values
|
||||||
|
|
||||||
|
3.3 WHEN no compliance uploads exist THEN the system SHALL CONTINUE TO return an empty trends array and the chart SHALL CONTINUE TO display the "no data" state
|
||||||
|
|
||||||
|
3.4 WHEN only one compliance upload exists per `report_date` THEN `GET /waterfall` SHALL CONTINUE TO emit one entry per date with the same `start`, `new_count`, `recurring_count`, `resolved_count`, and `end` fields and the same running-total semantics as before
|
||||||
|
|
||||||
|
3.5 WHEN only one compliance upload exists per `report_date` THEN `GET /category-trend` SHALL CONTINUE TO return one row per (date, category) pair with the same `report_date`, `category`, and `count` field shape as before
|
||||||
|
|
||||||
|
3.6 WHEN only one compliance upload exists for the latest `report_date` THEN `GET /summary` SHALL CONTINUE TO return the same `entries`, `overall_scores`, and `upload` shape as before, including the existing `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback for selecting which upload's summary to surface
|
||||||
|
|
||||||
|
3.7 WHEN `/summary` is called with a `team` query parameter THEN the system SHALL CONTINUE TO filter `entries` by the requested team and SHALL CONTINUE TO reject teams not in `ALLOWED_TEAMS` with HTTP 400
|
||||||
|
|
||||||
|
3.8 WHEN `persistUpload()` writes a snapshot for a vertical that is the only vertical present in `compliance_items` for that month THEN the snapshot row's `total_devices`, `compliant`, `non_compliant`, and `compliance_pct` SHALL CONTINUE TO be identical to the pre-fix values (no behavioural change in the single-vertical case)
|
||||||
|
|
||||||
|
3.9 WHEN `persistUpload()` encounters an error during snapshot creation THEN the system SHALL CONTINUE TO log the error and complete the upload commit successfully (snapshot creation remains non-critical)
|
||||||
|
|
||||||
|
3.10 WHEN any of these endpoints are queried with no matching data (no uploads, no items for a vertical, no items in a category) THEN the system SHALL CONTINUE TO return the existing empty-state response shapes
|
||||||
395
.kiro/specs/compliance-duplicate-chart-entries/design.md
Normal file
395
.kiro/specs/compliance-duplicate-chart-entries/design.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# Compliance Duplicate Chart Entries Bugfix Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Five compliance endpoints (`GET /trends`, `GET /top-recurring`, `GET /category-trend`, `GET /summary`) and the `compliance_snapshots` block inside `persistUpload()` all share the same root cause: they key by `compliance_uploads.id` (one row per uploaded xlsx) instead of by `compliance_uploads.report_date` (the calendar date the report covers). Because the compliance pipeline accepts one xlsx per vertical (NTS_AEO, SDIT_CISO, TSI), a single `report_date` typically maps to several `compliance_uploads` rows, and any query that does not aggregate over `report_date` produces duplicated, fragmented, or silently dropped data.
|
||||||
|
|
||||||
|
The fix is uniform across endpoints: rewrite the SQL so the result set has exactly one row per unique `report_date`, using `GROUP BY report_date` with `SUM` aggregations for count-style endpoints and `DISTINCT ON (report_date)` for the latest-snapshot endpoint. The `persistUpload()` snapshot block is fixed by adding a `vertical` filter so per-vertical snapshots are no longer cross-contaminated by other verticals' items.
|
||||||
|
|
||||||
|
The implementation is intentionally minimal: each fix changes a single SQL statement (and, in one case, a small JavaScript loop). No frontend changes are required — the chart components already key on `report_date` and will render correctly once the API returns one row per date.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Bug_Condition (C)**: The condition that triggers the bug — two or more rows in `compliance_uploads` share the same `report_date` (i.e., a multi-vertical upload day).
|
||||||
|
- **Property (P)**: The desired behavior when C holds — each affected endpoint returns exactly one entry per unique `report_date`, and the values aggregated across uploads for that date reconcile with the underlying `compliance_items` totals.
|
||||||
|
- **Preservation**: Behavior on dates with a single upload row, on the empty-data response shape, and on unrelated query parameters (e.g., `team` filter on `/summary`) — all must be byte-for-byte unchanged.
|
||||||
|
- **report_date**: `TEXT` column on `compliance_uploads` storing the reporting period the xlsx covers (e.g., `2025-05-11`). One date can have multiple upload rows when multiple verticals are uploaded for that date.
|
||||||
|
- **vertical**: `TEXT` column on `compliance_uploads` and `compliance_items` identifying which xlsx (NTS_AEO, SDIT_CISO, TSI) an upload or item belongs to. `NULL` indicates a legacy AEO-only upload.
|
||||||
|
- **persistUpload()**: Function in `backend/routes/compliance.js` (lines 81–192) that writes a parsed upload to the DB inside a transaction and then writes per-vertical snapshots into `compliance_snapshots`.
|
||||||
|
- **computeWaterfall(uploads)**: Pure helper in `backend/routes/compliance.js` (lines 235–243) that takes an ordered list of upload rows and emits one waterfall entry per row, carrying the running `start` forward.
|
||||||
|
|
||||||
|
## Bug Details
|
||||||
|
|
||||||
|
### Bug Condition
|
||||||
|
|
||||||
|
The bug manifests when two or more `compliance_uploads` rows share the same `report_date`. This happens whenever the operator uploads more than one vertical xlsx for the same reporting cycle (the documented multi-vertical workflow). The five affected code paths each produce one row per upload instead of aggregating to one row per `report_date`.
|
||||||
|
|
||||||
|
**Formal Specification:**
|
||||||
|
```
|
||||||
|
FUNCTION isBugCondition(uploads)
|
||||||
|
INPUT: uploads — list of compliance_uploads rows
|
||||||
|
OUTPUT: boolean
|
||||||
|
|
||||||
|
// The bug condition is triggered for any report_date that has more than one upload row
|
||||||
|
GROUP uploads BY report_date INTO groups
|
||||||
|
RETURN EXISTS group IN groups WHERE COUNT(group) > 1
|
||||||
|
END FUNCTION
|
||||||
|
```
|
||||||
|
|
||||||
|
For a single endpoint response to be considered buggy, the API output must additionally fail one of the following invariants (the per-endpoint manifestation of the same root cause):
|
||||||
|
|
||||||
|
```
|
||||||
|
FUNCTION isBuggyResponse(endpoint, response)
|
||||||
|
CASE endpoint OF
|
||||||
|
'/trends': RETURN COUNT(response.trends) != COUNT(DISTINCT report_date IN compliance_uploads)
|
||||||
|
'/top-recurring': RETURN COUNT(response.waterfall) != COUNT(DISTINCT report_date IN compliance_uploads)
|
||||||
|
'/category-trend': RETURN EXISTS (date, category) WITH COUNT(*) > 1 IN response.categoryTrend
|
||||||
|
'/summary': RETURN response.upload represents only one of N>1 uploads sharing the latest report_date
|
||||||
|
AND no flag indicates other uploads exist for that date
|
||||||
|
'persistUpload': RETURN snapshots.total_devices > items_belonging_to_this_vertical_only
|
||||||
|
END CASE
|
||||||
|
END FUNCTION
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
The originally reported case (GitLab issue #12, 2025-05-11) and the four sibling manifestations:
|
||||||
|
|
||||||
|
- **`/trends`** — STEAM uploads three xlsx files for `2025-05-11` (one per vertical). The chart shows three "05/11/25" entries on the x-axis instead of one. Expected: a single 05/11/25 point whose `new_count`/`recurring_count`/`resolved_count`/`total_active` are the sums of the three uploads' counts.
|
||||||
|
|
||||||
|
- **`/top-recurring`** — Same three uploads. `computeWaterfall()` receives three rows for `2025-05-11` and emits three bars stacked on the same date. Worse, because `start` carries forward across rows, the second and third bars' `start` reflects the first/second row's `end`, so the three bars in aggregate misrepresent the date-level deltas. Expected: one bar for `2025-05-11` whose `new_count`/`recurring_count`/`resolved_count` are summed across the three uploads, and whose `start` carries from the previous date's `end`.
|
||||||
|
|
||||||
|
- **`/category-trend`** — Same three uploads, each with category-tagged items. The query groups by `(cu.id, cu.report_date, category)` and returns up to `3 × |categories|` rows for `2025-05-11`. The frontend stacks these as duplicated category bars per date. Expected: one row per `(2025-05-11, category)` pair with `count` summed across the three uploads.
|
||||||
|
|
||||||
|
- **`/summary`** — On `2025-05-11`, three uploads exist. The query `WHERE vertical IS NULL ORDER BY id DESC LIMIT 1` (with fallback to `vertical = 'NTS_AEO'`) silently picks one and the other two verticals' `summary_json` is dropped. Expected: either the response merges all three uploads' `entries` and `overall_scores`, or the response includes a `multi_vertical_uploads` array identifying the other uploads that exist for the same `report_date` so the caller knows the response is partial.
|
||||||
|
|
||||||
|
- **Edge case — `persistUpload()` snapshot** — When SDIT_CISO is being persisted on `2025-05-11`, the snapshot query reads `compliance_items WHERE team IS NOT NULL` with no `vertical` filter, so the resulting per-team `total_devices`/`compliant`/`non_compliant` counts include items that belong to NTS_AEO and TSI as well. Expected: the snapshot query filters by the upload's `vertical` and groups by `(vertical, team)`.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
### Preservation Requirements
|
||||||
|
|
||||||
|
**Unchanged Behaviors:**
|
||||||
|
- Single-upload-per-date dates (legacy AEO-only workflow): every endpoint returns the same numbers, in the same shape, in the same order as before the fix.
|
||||||
|
- Empty-data responses: `/trends` returns `{ trends: [] }`, `/top-recurring` returns `{ waterfall: [] }`, `/category-trend` returns `{ categoryTrend: [] }`, `/summary` returns `{ entries: [], overall_scores: {}, upload: null }`.
|
||||||
|
- `/summary` `team` query parameter: still filters `entries` server-side, still rejects non-`ALLOWED_TEAMS` values with HTTP 400.
|
||||||
|
- `/summary` `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback for selecting which upload's `summary_json` to surface (only the additional metadata about sibling uploads is new).
|
||||||
|
- `persistUpload()` error handling: snapshot creation remains wrapped in a `try/catch` that logs but does not fail the upload commit.
|
||||||
|
- `compliance_snapshots` rows for months with only a single vertical present in `compliance_items`: identical values to the pre-fix output.
|
||||||
|
- Frontend chart components: no changes required. They already key on `report_date` and consume the existing response shapes.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
All endpoint inputs that do not involve `report_date` collisions (single-upload dates, empty datasets, error paths, query-parameter filtering) must be byte-for-byte identical to the pre-fix output. The fix only changes what happens when two or more `compliance_uploads` rows share a `report_date`.
|
||||||
|
|
||||||
|
## Hypothesized Root Cause
|
||||||
|
|
||||||
|
All five sites have the same shape of bug — keying by `id` instead of `report_date` — but with slightly different mechanics. Listing them explicitly so the test plan can confirm or refute each one:
|
||||||
|
|
||||||
|
1. **`/trends` — per-row mapping over uploads.** The handler runs `SELECT id, report_date, ... FROM compliance_uploads ORDER BY report_date ASC` and `.map()`s each row into a trend entry. Per-team counts are pre-aggregated by `upload_id` and looked up by `u.id`, so duplicate-date rows produce duplicate-date trend entries with split per-team counts.
|
||||||
|
|
||||||
|
2. **`/top-recurring` — `computeWaterfall()` receives per-row data.** The query is identical to `/trends`'s upload query and `computeWaterfall()` carries a stateful `start` forward across rows. Three rows for the same date become three bars whose `start`/`end` running totals are wrong relative to the date-level aggregate.
|
||||||
|
|
||||||
|
3. **`/category-trend` — `GROUP BY cu.id, cu.report_date, category`.** Including `cu.id` in the `GROUP BY` defeats date-level aggregation; one upload row's items get their own (date, category) group instead of summing into the date-level group.
|
||||||
|
|
||||||
|
4. **`/summary` — `ORDER BY id DESC LIMIT 1`.** The query selects a single representative upload for the latest date and discards every other upload sharing that date. This is a "select latest by row id" pattern that does not consider `report_date` ties.
|
||||||
|
|
||||||
|
5. **`persistUpload()` snapshot block — missing `vertical` filter.** The snapshot query reads `compliance_items WHERE team IS NOT NULL GROUP BY team` with no `vertical` predicate. The query was correct when there was one vertical (AEO-only legacy) and silently broke when the multi-vertical migration added a `vertical` column without updating this query.
|
||||||
|
|
||||||
|
The common structural cause is that the multi-vertical migration (`add_vcl_multi_vertical.js`) added a `vertical` column to `compliance_uploads` and `compliance_items` but did not audit existing read queries for the new "many uploads share a `report_date`" reality.
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
Property 1: Bug Condition — `/trends` returns one entry per unique report_date
|
||||||
|
|
||||||
|
_For any_ set of `compliance_uploads` rows where two or more rows share a `report_date`, the response from `GET /trends` SHALL contain exactly one entry per unique `report_date`, with `new_count`, `recurring_count`, `resolved_count`, and `total_active` equal to the SUM of those columns over all uploads sharing that date, and per-team counts equal to the sum of `compliance_items` rows for that team across all those uploads.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1, 2.2, 2.3**
|
||||||
|
|
||||||
|
Property 2: Bug Condition — `/top-recurring` waterfall has one bar per unique report_date with correct running totals
|
||||||
|
|
||||||
|
_For any_ set of `compliance_uploads` rows where two or more rows share a `report_date`, the response from `GET /top-recurring` SHALL contain exactly one waterfall entry per unique `report_date`, the entry's `new_count`/`recurring_count`/`resolved_count` SHALL equal the sum of those columns over all uploads sharing that date, and the running invariant `entry[i].end == entry[i].start + entry[i].new_count + entry[i].recurring_count - entry[i].resolved_count` SHALL hold with `entry[i].start == entry[i-1].end` for adjacent entries (and `entry[0].start == 0`).
|
||||||
|
|
||||||
|
**Validates: Requirements 2.4, 2.5**
|
||||||
|
|
||||||
|
Property 3: Bug Condition — `/category-trend` returns one row per (date, category)
|
||||||
|
|
||||||
|
_For any_ set of `compliance_uploads` and `compliance_items` rows, the response from `GET /category-trend` SHALL contain exactly one entry per unique `(report_date, category)` pair, and each entry's `count` SHALL equal the total number of `compliance_items` for that category across every upload sharing that `report_date`.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.6, 2.7**
|
||||||
|
|
||||||
|
Property 4: Bug Condition — `/summary` does not silently drop sibling uploads
|
||||||
|
|
||||||
|
_For any_ set of `compliance_uploads` rows where two or more rows share the latest `report_date`, the response from `GET /summary` SHALL either (a) include a merged view of all sibling uploads' `entries` and `overall_scores`, or (b) include a non-empty `multi_vertical_uploads` field listing the IDs and verticals of the other uploads for that date that were not used to populate the response. The response SHALL NOT silently drop sibling uploads.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.8, 2.9**
|
||||||
|
|
||||||
|
Property 5: Bug Condition — `persistUpload()` snapshot reflects only the snapshotted vertical
|
||||||
|
|
||||||
|
_For any_ `persistUpload()` invocation with a non-NULL `vertical`, the rows written into `compliance_snapshots` for the current month SHALL have `total_devices`, `compliant`, and `non_compliant` values equal to the counts derived from `compliance_items` filtered to the snapshotted vertical only. No item from another vertical SHALL contribute to those counts.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.10, 2.11**
|
||||||
|
|
||||||
|
Property 6: Preservation — Per-endpoint cross-date sums equal source-data totals
|
||||||
|
|
||||||
|
_For any_ set of uploads, summing `new_count` (and likewise `recurring_count`, `resolved_count`) across every entry in `GET /trends` SHALL equal the corresponding `SUM(new_count)` over `compliance_uploads`. Similarly, summing `count` across every entry in `GET /category-trend` SHALL equal `COUNT(*)` of `compliance_items` joined to `compliance_uploads`. This holds whether or not any date has duplicate uploads.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.1, 3.2**
|
||||||
|
|
||||||
|
Property 7: Preservation — Single-upload-per-date dates are unchanged
|
||||||
|
|
||||||
|
_For any_ set of `compliance_uploads` where every `report_date` has exactly one row, the responses from `/trends`, `/top-recurring`, `/category-trend`, and `/summary` (and the `compliance_snapshots` rows written by `persistUpload()`) SHALL be identical to the pre-fix output for the same input. The fix SHALL NOT change behavior on the single-upload-per-date case.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.1, 3.4, 3.5, 3.6, 3.8**
|
||||||
|
|
||||||
|
Property 8: Preservation — Empty-data and error-path responses are unchanged
|
||||||
|
|
||||||
|
_For any_ empty dataset (no uploads, no matching items, no items in a category), each affected endpoint SHALL return the same empty-state response shape as before the fix. `/summary` with a non-`ALLOWED_TEAMS` `team` parameter SHALL still respond `400`. `persistUpload()` snapshot errors SHALL still be caught and logged without failing the upload commit.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.3, 3.7, 3.9, 3.10**
|
||||||
|
|
||||||
|
## Fix Implementation
|
||||||
|
|
||||||
|
### Changes Required
|
||||||
|
|
||||||
|
All changes are in `backend/routes/compliance.js`. No schema migration, no new column, no frontend change.
|
||||||
|
|
||||||
|
#### Fix 1: `GET /trends` — aggregate uploads and team counts by `report_date`
|
||||||
|
|
||||||
|
**Function**: `router.get('/trends', ...)` (around line 768)
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Replace the `compliance_uploads` query so it groups by `report_date` and sums the count columns:
|
||||||
|
```sql
|
||||||
|
SELECT report_date,
|
||||||
|
SUM(COALESCE(new_count, 0))::int AS new_count,
|
||||||
|
SUM(COALESCE(recurring_count, 0))::int AS recurring_count,
|
||||||
|
SUM(COALESCE(resolved_count, 0))::int AS resolved_count,
|
||||||
|
SUM(COALESCE(new_count, 0) + COALESCE(recurring_count, 0))::int AS total_active
|
||||||
|
FROM compliance_uploads
|
||||||
|
WHERE report_date IS NOT NULL
|
||||||
|
GROUP BY report_date
|
||||||
|
ORDER BY report_date ASC
|
||||||
|
```
|
||||||
|
2. Replace the per-team `compliance_items` query so it joins to `compliance_uploads` and groups by `(report_date, team)` instead of `(upload_id, team)`:
|
||||||
|
```sql
|
||||||
|
SELECT cu.report_date, ci.team, COUNT(ci.id)::int AS count
|
||||||
|
FROM compliance_items ci
|
||||||
|
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
||||||
|
WHERE ci.team IS NOT NULL AND cu.report_date IS NOT NULL
|
||||||
|
GROUP BY cu.report_date, ci.team
|
||||||
|
```
|
||||||
|
3. Change the `teamMap` keyed lookup from `teamMap[u.id]` to `teamMap[u.report_date]` and rebuild `trends` from the per-date upload rows.
|
||||||
|
|
||||||
|
#### Fix 2: `GET /top-recurring` — aggregate uploads by `report_date` before passing to `computeWaterfall()`
|
||||||
|
|
||||||
|
**Function**: `router.get('/top-recurring', ...)` (around line 818)
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Replace the query with the same `GROUP BY report_date` pattern used in `/trends` (without `id`, since `computeWaterfall()` only needs `report_date`, `new_count`, `recurring_count`, `resolved_count`):
|
||||||
|
```sql
|
||||||
|
SELECT report_date,
|
||||||
|
SUM(COALESCE(new_count, 0))::int AS new_count,
|
||||||
|
SUM(COALESCE(recurring_count, 0))::int AS recurring_count,
|
||||||
|
SUM(COALESCE(resolved_count, 0))::int AS resolved_count
|
||||||
|
FROM compliance_uploads
|
||||||
|
WHERE report_date IS NOT NULL
|
||||||
|
GROUP BY report_date
|
||||||
|
ORDER BY report_date ASC
|
||||||
|
```
|
||||||
|
2. `computeWaterfall()` itself does not change — it already advances `start` correctly when fed one row per date. The fix is purely in the SQL.
|
||||||
|
|
||||||
|
#### Fix 3: `GET /category-trend` — drop `cu.id` from `GROUP BY`
|
||||||
|
|
||||||
|
**Function**: `router.get('/category-trend', ...)` (around line 838)
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Remove `cu.id` from the `GROUP BY` clause so the grouping is by `(report_date, category)` only:
|
||||||
|
```sql
|
||||||
|
SELECT cu.report_date,
|
||||||
|
COALESCE(ci.category, 'Unknown') AS category,
|
||||||
|
COUNT(ci.id)::int AS count
|
||||||
|
FROM compliance_uploads cu
|
||||||
|
JOIN compliance_items ci ON ci.upload_id = cu.id
|
||||||
|
WHERE cu.report_date IS NOT NULL
|
||||||
|
GROUP BY cu.report_date, COALESCE(ci.category, 'Unknown')
|
||||||
|
ORDER BY cu.report_date ASC, category ASC
|
||||||
|
```
|
||||||
|
2. The response shape (`{ categoryTrend: Array<{ report_date, category, count }> }`) does not change. Only the row count for multi-vertical dates changes (collapsing duplicates into sums).
|
||||||
|
|
||||||
|
#### Fix 4: `GET /summary` — disclose sibling uploads for the latest date
|
||||||
|
|
||||||
|
**Function**: `router.get('/summary', ...)` (around line 495)
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Keep the existing `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback for choosing the primary upload's `summary_json` (this preserves the legacy single-upload behavior).
|
||||||
|
2. After resolving `latestUpload`, run a second query to find sibling uploads sharing the same `report_date`:
|
||||||
|
```sql
|
||||||
|
SELECT id, vertical, uploaded_at
|
||||||
|
FROM compliance_uploads
|
||||||
|
WHERE report_date = $1 AND id != $2
|
||||||
|
ORDER BY id ASC
|
||||||
|
```
|
||||||
|
3. Add `multi_vertical_uploads` to the response when sibling uploads exist:
|
||||||
|
```javascript
|
||||||
|
res.json({
|
||||||
|
entries,
|
||||||
|
overall_scores: summary.overall_scores || {},
|
||||||
|
upload: { id, report_date, uploaded_at },
|
||||||
|
multi_vertical_uploads: siblings.map(s => ({ id: s.id, vertical: s.vertical, uploaded_at: s.uploaded_at })),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
4. When no sibling uploads exist (single-upload-per-date case), `multi_vertical_uploads` is `[]` (or omitted — see open question in test plan).
|
||||||
|
|
||||||
|
This is the conservative option (b) from requirement 2.8 — return a documented selection plus metadata about siblings — rather than option (a) full server-side merge. Option (b) is chosen because (i) the `summary_json` schema is per-vertical and merging would require reconciliation logic that doesn't currently exist, and (ii) the existing fallback selection (NTS_AEO) is the established representative for the legacy AEO chart on the Compliance page.
|
||||||
|
|
||||||
|
#### Fix 5: `persistUpload()` snapshot block — filter and group by `vertical`
|
||||||
|
|
||||||
|
**Function**: `persistUpload()` (lines 81–192), specifically the `verticalStats` query at line 157
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Determine the upload's `vertical` (read it from the upload row immediately after the `RETURNING id` insert, or accept it as a parameter to `persistUpload()`).
|
||||||
|
2. Replace the `verticalStats` query with one that filters by the upload's `vertical` and groups by `(vertical, team)`:
|
||||||
|
```sql
|
||||||
|
SELECT vertical, team,
|
||||||
|
COUNT(DISTINCT hostname)::int AS total_devices,
|
||||||
|
COUNT(DISTINCT CASE WHEN status = 'resolved' THEN hostname END)::int AS compliant,
|
||||||
|
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END)::int AS non_compliant
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE team IS NOT NULL AND vertical IS NOT DISTINCT FROM $1
|
||||||
|
GROUP BY vertical, team
|
||||||
|
```
|
||||||
|
(`IS NOT DISTINCT FROM` handles the legacy `vertical IS NULL` case correctly, so AEO-only uploads keep their previous semantics.)
|
||||||
|
3. The `INSERT ... ON CONFLICT (snapshot_month, vertical) DO UPDATE` already keys snapshots by `vertical`, so no change is required there. However, the `vertical` value passed in must come from the query result, not from `team AS vertical` (which conflates the team and vertical concepts).
|
||||||
|
4. If the per-snapshot-row "vertical" identity needs to remain `team` for back-compat reasons, leave the `INSERT` mapping unchanged but ensure the underlying counts are filtered to the upload's actual `vertical`. Confirm via inspection of `compliance_snapshots` consumers (`/vcl/stats`) before finalising.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Validation Approach
|
||||||
|
|
||||||
|
The bug condition is straightforward to construct: insert two `compliance_uploads` rows with the same `report_date` and matching `compliance_items`, then call each affected endpoint. The two-phase approach is to first run the tests against the unfixed code to confirm the duplication/silent-drop counterexamples, then run the same tests against the fixed code and add property-based tests that explore the input space more broadly.
|
||||||
|
|
||||||
|
### Exploratory Bug Condition Checking
|
||||||
|
|
||||||
|
**Goal**: Surface counterexamples that demonstrate each of the five manifestations BEFORE implementing the fix. Confirm or refute the root cause analysis for each endpoint independently — they share a structural cause but the SQL details differ.
|
||||||
|
|
||||||
|
**Test Plan**: Seed a clean test database with a fixture representing the original GitLab #12 scenario (three uploads for `2025-05-11`, one each for NTS_AEO, SDIT_CISO, TSI, with realistic `compliance_items`). Call each affected endpoint and assert the buggy invariants. Run on UNFIXED code first.
|
||||||
|
|
||||||
|
**Test Cases**:
|
||||||
|
|
||||||
|
1. **`/trends` Duplicate Date Test** — Insert three uploads for `2025-05-11` (verticals NTS_AEO, SDIT_CISO, TSI), each with distinct `new_count`/`recurring_count`/`resolved_count` and matching `compliance_items` per team. Call `GET /trends`. Assert `response.trends.filter(t => t.report_date === '2025-05-11').length === 1`. (will fail on unfixed code — returns 3)
|
||||||
|
|
||||||
|
2. **`/top-recurring` Duplicate Bar Test** — Same fixture. Call `GET /top-recurring`. Assert `response.waterfall.filter(w => w.date === '2025-05-11').length === 1` AND assert the running invariant `waterfall[i].end === waterfall[i].start + waterfall[i].new_count + waterfall[i].recurring_count - waterfall[i].resolved_count` holds for every `i`. (will fail on unfixed code — returns 3 bars and the running totals reflect mid-row state, not date-level aggregate)
|
||||||
|
|
||||||
|
3. **`/category-trend` Duplicate (date, category) Test** — Same fixture, plus items tagged with two categories (e.g., "Patching" and "Configuration"). Call `GET /category-trend`. Assert that for each `(report_date, category)` pair, `response.categoryTrend.filter(c => c.report_date === '2025-05-11' && c.category === 'Patching').length === 1`. (will fail on unfixed code — returns 3 rows per category)
|
||||||
|
|
||||||
|
4. **`/summary` Sibling Disclosure Test** — Same fixture (three uploads for `2025-05-11`, latest date). Call `GET /summary`. Assert either (a) the response merges `entries` from all three uploads, or (b) `response.multi_vertical_uploads.length === 2`. (will fail on unfixed code — silently picks one upload, the other two are dropped without any indication)
|
||||||
|
|
||||||
|
5. **`persistUpload()` Cross-Vertical Contamination Test** — Pre-populate `compliance_items` with rows from multiple verticals (e.g., NTS_AEO has 100 active items, SDIT_CISO has 50 active items). Call `persistUpload()` with a fresh SDIT_CISO upload. Read back the `compliance_snapshots` row for the current month and SDIT_CISO. Assert `total_devices` reflects only SDIT_CISO items, not the combined 150. (will fail on unfixed code — total includes both verticals)
|
||||||
|
|
||||||
|
6. **Edge Case — Single-Upload-Per-Date Regression Test** — Insert a fixture with a single upload per date for three dates. Call all four read endpoints and capture responses. Apply the fix, re-run, and assert response equality (byte-for-byte). (should pass on unfixed code; will pass on fixed code; protects the preservation property)
|
||||||
|
|
||||||
|
**Expected Counterexamples**:
|
||||||
|
- `/trends` returns N trend entries for a date with N uploads (N > 1). Cause: per-row `.map()` over uploads instead of date-level aggregation.
|
||||||
|
- `/top-recurring` returns N waterfall bars for a date with N uploads. Cause: same per-row pattern, plus `computeWaterfall()` carries `start` forward across the duplicate-date rows.
|
||||||
|
- `/category-trend` returns N × |categories| rows for a date with N uploads. Cause: `cu.id` is in the `GROUP BY` clause.
|
||||||
|
- `/summary` returns one upload's `summary_json` and silently drops siblings. Cause: `ORDER BY id DESC LIMIT 1` with no `report_date`-tie handling.
|
||||||
|
- `persistUpload()` writes inflated `total_devices`. Cause: missing `WHERE vertical = $1` and `GROUP BY vertical, team` in the snapshot query.
|
||||||
|
|
||||||
|
### Fix Checking
|
||||||
|
|
||||||
|
**Goal**: Verify that for all inputs where the bug condition holds (any `report_date` shared by two or more uploads), each fixed endpoint produces the expected aggregated/disclosed result.
|
||||||
|
|
||||||
|
**Pseudocode:**
|
||||||
|
```
|
||||||
|
FOR ALL (uploads, items) WHERE EXISTS report_date d WITH COUNT(uploads WHERE report_date = d) > 1 DO
|
||||||
|
trends_response := GET_trends_fixed(uploads, items)
|
||||||
|
waterfall_response := GET_top_recurring_fixed(uploads, items)
|
||||||
|
cattrend_response := GET_category_trend_fixed(uploads, items)
|
||||||
|
summary_response := GET_summary_fixed(uploads, items)
|
||||||
|
snapshot_rows := persistUpload_fixed(new_upload_for_some_vertical, items)
|
||||||
|
|
||||||
|
ASSERT one_entry_per_date(trends_response.trends)
|
||||||
|
ASSERT one_entry_per_date(waterfall_response.waterfall) AND running_invariant_holds(waterfall_response.waterfall)
|
||||||
|
ASSERT one_entry_per_date_category_pair(cattrend_response.categoryTrend)
|
||||||
|
ASSERT siblings_disclosed(summary_response, uploads)
|
||||||
|
ASSERT snapshots_filtered_to_vertical(snapshot_rows, new_upload.vertical, items)
|
||||||
|
END FOR
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preservation Checking
|
||||||
|
|
||||||
|
**Goal**: Verify that for all inputs where the bug condition does NOT hold (every `report_date` has exactly one upload row), the fixed endpoints produce results identical to the original endpoints.
|
||||||
|
|
||||||
|
**Pseudocode:**
|
||||||
|
```
|
||||||
|
FOR ALL (uploads, items) WHERE FORALL report_date d, COUNT(uploads WHERE report_date = d) <= 1 DO
|
||||||
|
ASSERT GET_trends_original(uploads, items) = GET_trends_fixed(uploads, items)
|
||||||
|
ASSERT GET_top_recurring_original(uploads, items) = GET_top_recurring_fixed(uploads, items)
|
||||||
|
ASSERT GET_category_trend_original(uploads, items) = GET_category_trend_fixed(uploads, items)
|
||||||
|
ASSERT GET_summary_original(uploads, items) = GET_summary_fixed(uploads, items)
|
||||||
|
ASSERT persistUpload_original(upload, items).snapshots = persistUpload_fixed(upload, items).snapshots
|
||||||
|
END FOR
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing Approach**: Property-based testing is the right fit for preservation checking here:
|
||||||
|
- The single-upload-per-date input space is large (any number of dates, any combination of counts, any team distribution, any category mix, any vertical), and exhaustive enumeration is impractical.
|
||||||
|
- The preservation property is a strict equality, which is well-suited to PBT shrinking (any counterexample is a small fixture demonstrating a behavior change).
|
||||||
|
- The legacy AEO-only data shape (`vertical IS NULL`) must be exercised, which falls naturally out of generators that include null verticals.
|
||||||
|
|
||||||
|
**Test Plan**: Capture responses from the unfixed code on single-upload-per-date fixtures (snapshot tests). After applying the fix, re-run the same fixtures and assert equality. Then run a property-based generator that produces random single-upload-per-date scenarios and asserts the same equality.
|
||||||
|
|
||||||
|
**Test Cases**:
|
||||||
|
1. **Snapshot Equality — Empty State** — Empty `compliance_uploads`. All four endpoints return their documented empty-state shapes. Snapshot-test before and after the fix.
|
||||||
|
2. **Snapshot Equality — Single AEO-Only Upload** — One upload with `vertical IS NULL`, classic legacy fixture. Capture pre-fix responses, apply fix, assert equality.
|
||||||
|
3. **Snapshot Equality — Multiple Single-Upload Dates** — Five dates, one upload each, varied `vertical` values. Capture pre-fix responses, apply fix, assert equality.
|
||||||
|
4. **`/summary` Team Filter Preservation** — Latest upload exists, `?team=STEAM` parameter is supplied. Assert `entries` is filtered to `team === 'STEAM'` rows. Assert non-`ALLOWED_TEAMS` value (e.g., `?team=OTHER`) returns HTTP 400.
|
||||||
|
5. **`persistUpload()` Snapshot Equality — Single-Vertical Month** — Pre-populate `compliance_items` with rows from a single vertical only. Run `persistUpload()` for that vertical. Assert the resulting `compliance_snapshots` rows are identical pre-fix and post-fix.
|
||||||
|
6. **Error Path Preservation** — Force a snapshot query failure (e.g., transient DB error). Assert `persistUpload()` still commits the upload and the error is logged but not surfaced to the caller.
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
- `/trends` aggregation: two uploads sharing a `report_date`, one upload alone for an earlier date. Assert response has 2 entries and `new_count` for the shared date equals the sum of the two uploads.
|
||||||
|
- `/top-recurring` aggregation and running totals: same fixture as above. Assert 2 waterfall entries and the running `start`/`end` invariant.
|
||||||
|
- `/category-trend` aggregation: two uploads sharing a `report_date`, items tagged with two categories. Assert one row per `(date, category)` pair with summed counts.
|
||||||
|
- `/summary` sibling disclosure: three uploads sharing the latest date. Assert response shape matches the chosen disclosure approach (option (b)).
|
||||||
|
- `/summary` team filter: same upload, with and without `?team=STEAM`.
|
||||||
|
- `persistUpload()` per-vertical snapshot: items in two verticals, run upload for one, assert snapshots for that vertical do not include the other vertical's items.
|
||||||
|
- `persistUpload()` legacy AEO-only path (`vertical IS NULL`): unchanged behavior.
|
||||||
|
|
||||||
|
### Property-Based Tests
|
||||||
|
|
||||||
|
- **`/trends` aggregation property** — Generate a random list of `(report_date, new_count, recurring_count, resolved_count)` upload tuples (with possible date collisions). Generate matching per-team item counts. Assert the response has exactly one entry per unique `report_date` AND for each entry, `new_count` equals the SUM of input `new_count`s for that date (likewise the other count fields and per-team counts).
|
||||||
|
- **`/top-recurring` running invariant property** — Same generator. Assert the response has one bar per unique `report_date` AND for every adjacent pair of entries, `entry[i].start === entry[i-1].end`, AND `entry[i].end === entry[i].start + entry[i].new_count + entry[i].recurring_count - entry[i].resolved_count`.
|
||||||
|
- **`/category-trend` total-conservation property** — Generate a random set of `compliance_items` and uploads. Assert `SUM(response.categoryTrend.map(c => c.count)) === total number of compliance_items joined to non-null-report_date uploads`. This holds whether or not any date has multiple uploads.
|
||||||
|
- **`/summary` sibling-disclosure property** — Generate a random set of uploads with possible duplicate `report_dates`. Pick the latest date. Assert that if any sibling upload exists for that date, the response contains a non-empty `multi_vertical_uploads` array referencing every sibling upload's id.
|
||||||
|
- **`persistUpload()` vertical-isolation property** — Generate two non-empty disjoint sets of `compliance_items`, one per vertical. Insert both. Run `persistUpload()` for vertical A. Assert the resulting `compliance_snapshots` rows for vertical A reflect only set-A items (count of distinct hostnames matches).
|
||||||
|
- **Cross-endpoint preservation property** — Generate any fixture where every `report_date` has exactly one upload row. Assert all five fixed endpoints produce byte-for-byte identical results to the original endpoints.
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- Full upload-to-chart flow: upload three xlsx files (one per vertical) with the same `report_date` via `POST /preview` + `POST /commit`, then call `/trends`, `/top-recurring`, `/category-trend`, `/summary` and verify all four return the expected aggregated/disclosed results.
|
||||||
|
- Compliance Charts panel render: load `ComplianceChartsPanel.js` with a multi-vertical-day fixture and assert (via DOM snapshot) the x-axis shows each date exactly once on `Active Findings Over Time` and `Change per Report Cycle`.
|
||||||
|
- Snapshot consumer regression: after running `persistUpload()` with the fix, call `/vcl/stats` (which reads `compliance_snapshots`) and verify per-vertical `compliance_pct` is unchanged from the pre-fix value when only one vertical's items are present, and is corrected when multiple verticals are present.
|
||||||
|
|
||||||
|
### Test Fixtures Required
|
||||||
|
|
||||||
|
The following fixtures are needed and can be reused across all five endpoints' tests:
|
||||||
|
|
||||||
|
1. **`fixture_empty`** — No `compliance_uploads`, no `compliance_items`. Used by the empty-state preservation tests.
|
||||||
|
|
||||||
|
2. **`fixture_single_upload_aeo_legacy`** — One `compliance_uploads` row with `vertical IS NULL`, `report_date = '2025-04-01'`, with ~20 `compliance_items` distributed across the four teams. Used by the legacy-path preservation tests.
|
||||||
|
|
||||||
|
3. **`fixture_single_upload_per_date`** — Five `compliance_uploads` rows, each with a distinct `report_date` (`2025-04-01` through `2025-05-01`), each with a distinct `vertical` value among `{NTS_AEO, SDIT_CISO, TSI, NULL, NTS_AEO}`. Used by the broader preservation tests and by `/category-trend` total-conservation.
|
||||||
|
|
||||||
|
4. **`fixture_multi_vertical_single_date`** — Three `compliance_uploads` rows all with `report_date = '2025-05-11'`, verticals NTS_AEO/SDIT_CISO/TSI, each with distinct `new_count`/`recurring_count`/`resolved_count` and 5–10 `compliance_items` per upload spanning multiple teams and categories. This is the canonical bug-condition fixture and reproduces the original GitLab #12 scenario.
|
||||||
|
|
||||||
|
5. **`fixture_mixed_history`** — Combination of `fixture_single_upload_per_date` and `fixture_multi_vertical_single_date` — multiple dates, some with single uploads, some with two or three. Used by the property-based tests as a realistic state-of-the-world fixture.
|
||||||
|
|
||||||
|
6. **`fixture_cross_vertical_items`** — Two non-empty disjoint sets of `compliance_items`, one tagged `vertical = 'NTS_AEO'` and one tagged `vertical = 'SDIT_CISO'`, sharing some hostnames between verticals to ensure the count-distinct logic is exercised. Used by the `persistUpload()` vertical-isolation tests.
|
||||||
|
|
||||||
|
7. **`fixture_pbt_generators`** — fast-check (or equivalent) arbitraries:
|
||||||
|
- `arbReportDate`: ISO date string in a bounded range (e.g., last 90 days).
|
||||||
|
- `arbVertical`: oneof `'NTS_AEO' | 'SDIT_CISO' | 'TSI' | null`.
|
||||||
|
- `arbUpload`: `{ report_date, vertical, new_count, recurring_count, resolved_count }` with non-negative integer counts.
|
||||||
|
- `arbItem`: `{ hostname, team in ALLOWED_TEAMS, category in {Patching, Configuration, Vulnerability, Other}, vertical, status in {active, resolved} }`.
|
||||||
|
- `arbScenario`: `{ uploads: arbUpload[], items: arbItem[] }`, where items reference uploads via `upload_id` and dates can collide.
|
||||||
179
.kiro/specs/compliance-duplicate-chart-entries/tasks.md
Normal file
179
.kiro/specs/compliance-duplicate-chart-entries/tasks.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Five compliance code paths share the root cause "key by `compliance_uploads.id` instead of by `compliance_uploads.report_date`": `GET /trends`, `GET /top-recurring`, `GET /category-trend`, `GET /summary`, and the `compliance_snapshots` block inside `persistUpload()`. All fixes are contained to `backend/routes/compliance.js` and require no schema migration, no new column, and no frontend change.
|
||||||
|
|
||||||
|
The plan follows the bugfix workflow's exploratory methodology: a single property-based test file (`backend/__tests__/compliance-duplicate-chart-entries.property.test.js`) is written before any fix, with one test case per affected site demonstrating the bug condition, plus preservation cases observed on the unfixed code. Each fix is then implemented as its own task and verified by re-running the matching test case from the exploration suite. The plan ends with a regression checkpoint that re-runs the full preservation suite and the backend test suite.
|
||||||
|
|
||||||
|
## Task Dependency Graph
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"waves": [
|
||||||
|
{ "id": 0, "tasks": ["1", "2"] },
|
||||||
|
{ "id": 1, "tasks": ["3.1", "4.1", "5.1", "6.1", "7.1"] },
|
||||||
|
{ "id": 2, "tasks": ["3.2", "4.2", "5.2", "6.2", "7.2"] },
|
||||||
|
{ "id": 3, "tasks": ["8"] },
|
||||||
|
{ "id": 4, "tasks": ["9"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wave 0 establishes the test baseline: task 1 documents Property 1 (bug condition) failures on the unfixed code and task 2 captures Property 2 (preservation) baseline outputs. Wave 1 implements the five independent fixes in `backend/routes/compliance.js` (no inter-fix dependencies — each touches a different SQL statement). Wave 2 verifies each fix's slice of Property 1 now passes. Wave 3 re-runs the full Property 2 suite to confirm no regressions across the five sites. Wave 4 is the final checkpoint that runs the entire backend test suite.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Write bug condition exploration property test
|
||||||
|
- **Property 1: Bug Condition** - Multi-Vertical Date Aggregation Across Five Compliance Sites
|
||||||
|
- **CRITICAL**: This test MUST FAIL on unfixed code — failure confirms the bug exists across all five sites
|
||||||
|
- **DO NOT attempt to fix the test or the code when it fails**
|
||||||
|
- **NOTE**: This test encodes the expected behavior — it will validate the fixes when it passes after implementation
|
||||||
|
- **GOAL**: Surface counterexamples that demonstrate the bug exists for each of the five affected code paths (`/trends`, `/top-recurring`, `/category-trend`, `/summary`, `persistUpload()` snapshot block)
|
||||||
|
- **Scoped PBT Approach**: Scope the property to the canonical bug-condition fixture (`fixture_multi_vertical_single_date` from design.md) — three uploads for `2025-05-11`, one each for `NTS_AEO`, `SDIT_CISO`, `TSI`, with distinct counts and matching items per upload — this reproduces the original GitLab #12 scenario deterministically
|
||||||
|
- Bug Condition (from design.md): `EXISTS report_date d WHERE COUNT(compliance_uploads WHERE report_date = d) > 1`
|
||||||
|
- Create `backend/__tests__/compliance-duplicate-chart-entries.property.test.js` using `fast-check` and the existing pg pool mock pattern from `vcl-compliance-reporting.property.test.js`
|
||||||
|
- Test case 1.A — `/trends` duplicate-date counterexample: seed three uploads for `2025-05-11` (verticals NTS_AEO/SDIT_CISO/TSI), call `GET /trends`, assert `response.trends.filter(t => t.report_date === '2025-05-11').length === 1` AND `new_count` for that date equals the sum of the three uploads' `new_count` values (likewise `recurring_count`, `resolved_count`, `total_active`, and per-team counts)
|
||||||
|
- Test case 1.B — `/top-recurring` duplicate-bar counterexample: same fixture, call `GET /top-recurring`, assert exactly one waterfall entry per unique `report_date` AND the running invariant `entry[i].end === entry[i].start + entry[i].new_count + entry[i].recurring_count - entry[i].resolved_count` holds for every `i` AND `entry[i].start === entry[i-1].end` for adjacent entries (with `entry[0].start === 0`)
|
||||||
|
- Test case 1.C — `/category-trend` duplicate (date, category) counterexample: same fixture plus items tagged with two categories (`Patching` and `Configuration`), call `GET /category-trend`, assert `response.categoryTrend.filter(c => c.report_date === '2025-05-11' && c.category === 'Patching').length === 1` AND each entry's `count` equals the total `compliance_items` for that category across every upload sharing the date
|
||||||
|
- Test case 1.D — `/summary` sibling-disclosure counterexample: same fixture (`2025-05-11` is the latest date), call `GET /summary`, assert either (a) `entries` is the merged view of all three uploads OR (b) `response.multi_vertical_uploads` is a non-empty array with `length === 2` listing the other two uploads' ids and verticals
|
||||||
|
- Test case 1.E — `persistUpload()` cross-vertical contamination counterexample: pre-populate `compliance_items` with disjoint sets for two verticals (e.g., NTS_AEO has 100 active items, SDIT_CISO has 50 active items), call `persistUpload()` for a fresh SDIT_CISO upload, read back the `compliance_snapshots` row for the current month with `vertical = 'SDIT_CISO'`, assert `total_devices` reflects only SDIT_CISO items and is not inflated by NTS_AEO items
|
||||||
|
- Wrap each test case in fast-check `fc.assert` against `arbScenario` from design.md (uploads with possibly colliding `report_date`, items referencing those uploads) so PBT also exercises larger random fixtures beyond the canonical 3-upload case
|
||||||
|
- Add fixture builders in the test file matching the design's `fixture_multi_vertical_single_date` and `fixture_cross_vertical_items`
|
||||||
|
- Run test on UNFIXED code: `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js`
|
||||||
|
- **EXPECTED OUTCOME**: All five test cases FAIL (this is correct — it proves each manifestation of the bug exists)
|
||||||
|
- Document the counterexamples found (e.g., `/trends` returns 3 entries for `2025-05-11` instead of 1, `/summary` returns one upload's `summary_json` and silently drops the other two, `compliance_snapshots.total_devices` for SDIT_CISO equals 150 instead of 50)
|
||||||
|
- Mark task complete when test is written, run, and the failures for all five test cases are documented
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11_
|
||||||
|
|
||||||
|
- [x] 2. Write preservation property tests (BEFORE implementing fix)
|
||||||
|
- **Property 2: Preservation** - Single-Upload-Per-Date Behavior Unchanged Across Five Sites
|
||||||
|
- **IMPORTANT**: Follow observation-first methodology — capture pre-fix outputs and assert equality post-fix
|
||||||
|
- Bug Condition negation (from design.md): `FORALL report_date d, COUNT(compliance_uploads WHERE report_date = d) <= 1`
|
||||||
|
- Add preservation test cases to `backend/__tests__/compliance-duplicate-chart-entries.property.test.js`
|
||||||
|
- Observe baseline behavior on UNFIXED code using the design's preservation fixtures and capture exact response bodies (snapshot-test style)
|
||||||
|
- Test case 2.A — Empty-state preservation (`fixture_empty`): no `compliance_uploads`, no `compliance_items`. Observe `GET /trends` returns `{ trends: [] }`, `GET /top-recurring` returns `{ waterfall: [] }`, `GET /category-trend` returns `{ categoryTrend: [] }`, `GET /summary` returns `{ entries: [], overall_scores: {}, upload: null }`. Capture and assert these exact shapes
|
||||||
|
- Test case 2.B — Single AEO-legacy-upload preservation (`fixture_single_upload_aeo_legacy`): one upload with `vertical IS NULL`, `report_date = '2025-04-01'`, ~20 items across the four teams. Observe responses from all four read endpoints, capture them, and assert byte-for-byte equality
|
||||||
|
- Test case 2.C — Multiple single-upload-per-date preservation (`fixture_single_upload_per_date`): five uploads on five distinct dates with varied `vertical` values. Observe responses from all four read endpoints and assert equality
|
||||||
|
- Test case 2.D — `/summary` `team` query parameter preservation: with the latest upload present, assert `?team=STEAM` filters `entries` server-side AND `?team=OTHER` (non-`ALLOWED_TEAMS`) returns HTTP 400. Capture both responses
|
||||||
|
- Test case 2.E — `persistUpload()` single-vertical-month preservation (`fixture_cross_vertical_items` reduced to one vertical): pre-populate `compliance_items` with rows from a single vertical only, run `persistUpload()` for that vertical, capture the resulting `compliance_snapshots` rows
|
||||||
|
- Test case 2.F — `persistUpload()` snapshot error-path preservation: force a snapshot query failure (mock `pool.query` to reject on the snapshot statement only), assert the upload still commits and the error is logged but not surfaced (HTTP 200/201, no error response)
|
||||||
|
- Property-based extension — Cross-endpoint preservation: use fast-check `arbScenario` constrained to scenarios where every `report_date` has exactly one upload row. Assert that for every generated scenario, all four endpoint responses on UNFIXED code match the captured-baseline shape and field-level equality holds (this generator covers the design's `fixture_pbt_generators.arbScenario` restricted to the non-bug-condition input space)
|
||||||
|
- Run tests on UNFIXED code: `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js`
|
||||||
|
- **EXPECTED OUTCOME**: All preservation test cases PASS (this confirms the baseline behavior to preserve)
|
||||||
|
- Mark task complete when tests are written, run, and passing on unfixed code
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_
|
||||||
|
|
||||||
|
- [x] 3. Fix `/trends` — aggregate uploads and team counts by `report_date`
|
||||||
|
|
||||||
|
- [x] 3.1 Rewrite the `/trends` upload and team queries to group by `report_date`
|
||||||
|
- In `backend/routes/compliance.js` `router.get('/trends', ...)` (around line 768), replace the `compliance_uploads` query with the `GROUP BY report_date` SQL from design.md Fix 1, summing `new_count`, `recurring_count`, `resolved_count`, and `(new_count + recurring_count) AS total_active`
|
||||||
|
- Replace the per-team `compliance_items` query with the `JOIN compliance_uploads` + `GROUP BY cu.report_date, ci.team` form from design.md Fix 1
|
||||||
|
- Change the `teamMap` keyed lookup from `teamMap[u.id]` to `teamMap[u.report_date]` and rebuild `trends` from the per-date upload rows
|
||||||
|
- _Bug_Condition: isBugCondition(uploads) where two or more compliance_uploads rows share a `report_date`_
|
||||||
|
- _Expected_Behavior: GET /trends returns one entry per unique report_date with summed count fields and aggregated per-team counts (Property 1 from design)_
|
||||||
|
- _Preservation: Single-upload-per-date dates produce identical responses; empty-data response remains `{ trends: [] }`; chart components require no changes_
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3_
|
||||||
|
|
||||||
|
- [x] 3.2 Verify the `/trends` portion of bug condition exploration test now passes
|
||||||
|
- **Property 1: Expected Behavior** - `/trends` Returns One Entry Per Unique report_date
|
||||||
|
- **IMPORTANT**: Re-run the SAME test case 1.A from task 1 — do NOT write a new test
|
||||||
|
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "/trends"`
|
||||||
|
- **EXPECTED OUTCOME**: Test case 1.A PASSES (confirms `/trends` bug is fixed)
|
||||||
|
- _Requirements: Property 1 (Validates 2.1, 2.2, 2.3) from design_
|
||||||
|
|
||||||
|
- [x] 4. Fix `/top-recurring` — aggregate uploads by `report_date` before passing to `computeWaterfall()`
|
||||||
|
|
||||||
|
- [x] 4.1 Rewrite the `/top-recurring` upload query to group by `report_date`
|
||||||
|
- In `backend/routes/compliance.js` `router.get('/top-recurring', ...)` (around line 818), replace the query with the `GROUP BY report_date` SQL from design.md Fix 2, summing `new_count`, `recurring_count`, `resolved_count`
|
||||||
|
- Leave `computeWaterfall()` unchanged — it already advances `start` correctly when fed one row per date; the fix is purely in the SQL
|
||||||
|
- _Bug_Condition: isBugCondition(uploads) where two or more compliance_uploads rows share a `report_date`_
|
||||||
|
- _Expected_Behavior: GET /top-recurring returns one waterfall entry per unique report_date with summed deltas; running invariant `entry[i].end === entry[i].start + entry[i].new_count + entry[i].recurring_count - entry[i].resolved_count` holds and `entry[i].start === entry[i-1].end` for adjacent entries (Property 2 from design)_
|
||||||
|
- _Preservation: Single-upload-per-date waterfall is unchanged; empty-data response remains `{ waterfall: [] }`_
|
||||||
|
- _Requirements: 2.4, 2.5, 3.4_
|
||||||
|
|
||||||
|
- [x] 4.2 Verify the `/top-recurring` portion of bug condition exploration test now passes
|
||||||
|
- **Property 1: Expected Behavior** - `/top-recurring` Has One Bar Per Unique report_date With Correct Running Totals
|
||||||
|
- **IMPORTANT**: Re-run the SAME test case 1.B from task 1 — do NOT write a new test
|
||||||
|
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "/top-recurring"`
|
||||||
|
- **EXPECTED OUTCOME**: Test case 1.B PASSES (confirms `/top-recurring` bug is fixed and the running invariant holds)
|
||||||
|
- _Requirements: Property 2 (Validates 2.4, 2.5) from design_
|
||||||
|
|
||||||
|
- [x] 5. Fix `/category-trend` — drop `cu.id` from `GROUP BY`
|
||||||
|
|
||||||
|
- [x] 5.1 Rewrite the `/category-trend` query to group by `(report_date, category)` only
|
||||||
|
- In `backend/routes/compliance.js` `router.get('/category-trend', ...)` (around line 838), replace the query with the SQL from design.md Fix 3 — remove `cu.id` from the `GROUP BY` so grouping is by `(cu.report_date, COALESCE(ci.category, 'Unknown'))` only
|
||||||
|
- Leave the response shape `{ categoryTrend: Array<{ report_date, category, count }> }` unchanged
|
||||||
|
- _Bug_Condition: isBugCondition(uploads) where two or more compliance_uploads rows share a `report_date`_
|
||||||
|
- _Expected_Behavior: GET /category-trend returns one row per unique (report_date, category) pair with `count` equal to the total `compliance_items` for that category across every upload sharing the date (Property 3 from design)_
|
||||||
|
- _Preservation: Single-upload-per-date rows are unchanged; empty-data response remains `{ categoryTrend: [] }`; total-conservation property holds across all dates (Property 6 from design)_
|
||||||
|
- _Requirements: 2.6, 2.7, 3.5_
|
||||||
|
|
||||||
|
- [x] 5.2 Verify the `/category-trend` portion of bug condition exploration test now passes
|
||||||
|
- **Property 1: Expected Behavior** - `/category-trend` Returns One Row Per (date, category)
|
||||||
|
- **IMPORTANT**: Re-run the SAME test case 1.C from task 1 — do NOT write a new test
|
||||||
|
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "/category-trend"`
|
||||||
|
- **EXPECTED OUTCOME**: Test case 1.C PASSES (confirms `/category-trend` bug is fixed)
|
||||||
|
- _Requirements: Property 3 (Validates 2.6, 2.7) from design_
|
||||||
|
|
||||||
|
- [x] 6. Fix `/summary` — disclose sibling uploads for the latest date
|
||||||
|
|
||||||
|
- [x] 6.1 Add sibling-upload disclosure to the `/summary` response
|
||||||
|
- In `backend/routes/compliance.js` `router.get('/summary', ...)` (around line 495), keep the existing `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback for selecting the primary upload's `summary_json` (preserves legacy single-upload behavior per requirement 3.6)
|
||||||
|
- After resolving `latestUpload`, run the second query from design.md Fix 4 to find sibling uploads sharing the same `report_date`: `SELECT id, vertical, uploaded_at FROM compliance_uploads WHERE report_date = $1 AND id != $2 ORDER BY id ASC`
|
||||||
|
- Add `multi_vertical_uploads` to the response, populated with `siblings.map(s => ({ id, vertical, uploaded_at }))`; the field is `[]` when no siblings exist
|
||||||
|
- Do not change the `team` query parameter handling, the `ALLOWED_TEAMS` HTTP 400 response, or the `entries`/`overall_scores`/`upload` shape
|
||||||
|
- _Bug_Condition: isBugCondition(uploads) where two or more compliance_uploads rows share the latest `report_date`_
|
||||||
|
- _Expected_Behavior: GET /summary either merges sibling uploads' entries OR exposes a non-empty `multi_vertical_uploads` array identifying the other uploads for the same `report_date`; sibling uploads are never silently dropped (Property 4 from design)_
|
||||||
|
- _Preservation: Single-upload-per-date `/summary` shape is unchanged (the `vertical IS NULL` → `vertical = 'NTS_AEO'` fallback still runs); `team` query parameter still filters entries and rejects non-`ALLOWED_TEAMS` with HTTP 400; empty-data response remains `{ entries: [], overall_scores: {}, upload: null }`_
|
||||||
|
- _Requirements: 2.8, 2.9, 3.6, 3.7_
|
||||||
|
|
||||||
|
- [x] 6.2 Verify the `/summary` portion of bug condition exploration test now passes
|
||||||
|
- **Property 1: Expected Behavior** - `/summary` Does Not Silently Drop Sibling Uploads
|
||||||
|
- **IMPORTANT**: Re-run the SAME test case 1.D from task 1 — do NOT write a new test
|
||||||
|
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "/summary"`
|
||||||
|
- **EXPECTED OUTCOME**: Test case 1.D PASSES (confirms `/summary` discloses sibling uploads via `multi_vertical_uploads`)
|
||||||
|
- _Requirements: Property 4 (Validates 2.8, 2.9) from design_
|
||||||
|
|
||||||
|
- [x] 7. Fix `persistUpload()` snapshot block — filter and group by `vertical`
|
||||||
|
|
||||||
|
- [x] 7.1 Rewrite the `verticalStats` query to filter by the upload's `vertical`
|
||||||
|
- In `backend/routes/compliance.js` `persistUpload()` (lines 81–192), at the `verticalStats` query around line 157, capture the upload's `vertical` from the row returned by the `RETURNING id` insert (or accept it as a `persistUpload()` parameter)
|
||||||
|
- Replace the `verticalStats` query with the SQL from design.md Fix 5: filter `WHERE team IS NOT NULL AND vertical IS NOT DISTINCT FROM $1` and group by `(vertical, team)`. The `IS NOT DISTINCT FROM` operator handles the legacy `vertical IS NULL` case so AEO-only uploads keep their previous semantics
|
||||||
|
- Leave the existing `INSERT ... ON CONFLICT (snapshot_month, vertical) DO UPDATE` mapping as-is so `compliance_snapshots` consumers (`/vcl/stats`) continue to read the same column shape; only the underlying counts change
|
||||||
|
- Keep snapshot creation wrapped in the existing `try/catch` so a snapshot failure is logged and does not fail the upload commit
|
||||||
|
- _Bug_Condition: isBugCondition for `persistUpload()` is `compliance_items` containing rows for verticals other than the upload's vertical — the unfiltered query inflates `total_devices`/`compliant`/`non_compliant`_
|
||||||
|
- _Expected_Behavior: compliance_snapshots rows written by persistUpload() have `total_devices`, `compliant`, `non_compliant` derived only from compliance_items rows belonging to the snapshotted vertical; no item from another vertical contributes (Property 5 from design)_
|
||||||
|
- _Preservation: Single-vertical months produce identical snapshot rows; the legacy `vertical IS NULL` AEO-only path is unchanged via `IS NOT DISTINCT FROM`; the snapshot try/catch error path is unchanged_
|
||||||
|
- _Requirements: 2.10, 2.11, 3.8, 3.9_
|
||||||
|
|
||||||
|
- [x] 7.2 Verify the `persistUpload()` portion of bug condition exploration test now passes
|
||||||
|
- **Property 1: Expected Behavior** - persistUpload() Snapshot Reflects Only the Snapshotted Vertical
|
||||||
|
- **IMPORTANT**: Re-run the SAME test case 1.E from task 1 — do NOT write a new test
|
||||||
|
- Run `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "persistUpload"`
|
||||||
|
- **EXPECTED OUTCOME**: Test case 1.E PASSES (confirms snapshot rows are filtered to the snapshotted vertical only)
|
||||||
|
- _Requirements: Property 5 (Validates 2.10, 2.11) from design_
|
||||||
|
|
||||||
|
- [x] 8. Verify preservation tests still pass after all five fixes
|
||||||
|
- **Property 2: Preservation** - Single-Upload-Per-Date Behavior Unchanged
|
||||||
|
- **IMPORTANT**: Re-run the SAME tests from task 2 — do NOT write new tests
|
||||||
|
- Run preservation property tests from task 2: `npm run test:backend -- compliance-duplicate-chart-entries.property.test.js -t "Preservation"`
|
||||||
|
- Confirm all six preservation test cases (2.A–2.F) and the cross-endpoint property-based preservation extension still pass
|
||||||
|
- **EXPECTED OUTCOME**: Tests PASS (confirms no regressions across the four read endpoints, the `/summary` `team` filter, the `persistUpload()` single-vertical-month path, and the snapshot error-path behavior)
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_
|
||||||
|
|
||||||
|
- [x] 9. Checkpoint — Ensure all tests pass
|
||||||
|
- Run the full backend test suite: `npm run test:backend`
|
||||||
|
- Confirm `compliance-duplicate-chart-entries.property.test.js` passes end-to-end (both Property 1 expected-behavior cases and Property 2 preservation cases)
|
||||||
|
- Confirm pre-existing tests (`vcl-compliance-reporting.property.test.js`, `vcl-aggregated-burndown.property.test.js`, `vcl-aggregated-burndown.test.js`, `vcl-compliance-reporting.test.js`, `fp-submissions-cleanup.test.js`, etc.) still pass — none of these should be affected since the fix is contained to read queries and one snapshot write query in `compliance.js`
|
||||||
|
- Spot-check the integration scenarios from design.md "Integration Tests": upload three xlsx files for the same `report_date` via `POST /preview` + `POST /commit`, then call `/trends`, `/top-recurring`, `/category-trend`, `/summary` and verify aggregated/disclosed responses; call `/vcl/stats` and verify per-vertical `compliance_pct` is correct
|
||||||
|
- Ensure all tests pass, ask the user if questions arise
|
||||||
|
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All five fixes are contained to `backend/routes/compliance.js`. No database migration, no new column, no frontend change.
|
||||||
|
- The Property 1 / Property 2 task numbering follows the bugfix workflow convention so the IDE hover-status indicator can track exploration tests against expected-behavior verification. Task 1 is the single Property 1 source; tasks 3.2 / 4.2 / 5.2 / 6.2 / 7.2 each re-run the relevant slice of Property 1 (NOT new tests) to confirm the matching fix lands correctly. Task 2 is the single Property 2 source; task 8 re-runs Property 2 in full to confirm no regressions.
|
||||||
|
- The implementation tasks (3 through 7) are independent at the SQL level. Each can be reviewed and merged without waiting on the others, as long as task 1 and task 2 have run on the unfixed code first.
|
||||||
|
- The `_Bug_Condition`, `_Expected_Behavior`, and `_Preservation` annotations on each fix sub-task reference the formal pseudocode in `design.md` Glossary and Bug Details sections.
|
||||||
|
- `_Requirements: X.Y_` annotations cite clauses in `bugfix.md` Bug Analysis.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "7a1ca671-3974-49b1-8e83-023077e758d5", "workflowType": "requirements-first", "specType": "bugfix"}
|
||||||
65
.kiro/specs/compliance-duplicate-failing-metrics/bugfix.md
Normal file
65
.kiro/specs/compliance-duplicate-failing-metrics/bugfix.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Bugfix Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Several Compliance backend endpoints select from `compliance_items` without scoping to a single vertical, so when the same `(hostname, metric_id)` exists in both a legacy `vertical IS NULL` upload and a multi-vertical `vertical = 'NTS_AEO'` upload, the duplicate rows distort the response. The originally reported symptom is on the device-level violation view: hostname `STEAM-INTERSIGHT` (IP 172.16.30.40) shows metric `7.1.1` listed twice in its failing metrics list (GitLab issue #13, reported by nkapur). Investigation found the same duplication pattern in additional endpoints that drive per-team and per-vertical reporting.
|
||||||
|
|
||||||
|
This spec covers the full class of "duplicate `(hostname, metric_id)` rows across verticals" bugs in `backend/routes/compliance.js`. The affected surfaces are:
|
||||||
|
|
||||||
|
1. `GET /items` — failing metrics list per device (originally reported)
|
||||||
|
2. `GET /items/:hostname` — device detail metrics array (originally reported)
|
||||||
|
3. `persistUpload()` `compliance_snapshots` creation block — per-vertical compliant/non-compliant counts
|
||||||
|
4. `GET /vcl/stats` — heavy-hitters team counts, per-team device totals, and forecast-burndown row counts
|
||||||
|
5. `GET /mttr` — per-team aging bucket counts
|
||||||
|
|
||||||
|
**Root Cause:** Each affected query selects from `compliance_items` either with a vertical filter that admits both legacy and multi-vertical rows (`vertical IS NULL OR vertical = 'NTS_AEO'`) or with no vertical filter at all. Some queries dedupe at the hostname level via `COUNT(DISTINCT hostname)`, which protects against per-team device totals being inflated, but does not protect aggregations that depend on `(hostname, metric_id)` uniqueness, status uniqueness per hostname, or team uniqueness per hostname. The `groupByHostname` helper and the `/items/:hostname` query likewise have no deduplication at all, so every duplicate row becomes a duplicate metric in the response.
|
||||||
|
|
||||||
|
## Bug Analysis
|
||||||
|
|
||||||
|
### Current Behavior (Defect)
|
||||||
|
|
||||||
|
1.1 WHEN a device has compliance_items rows for the same (hostname, metric_id) pair across multiple verticals (e.g., one row with `vertical IS NULL` and another with `vertical = 'NTS_AEO'`) THEN the `/items` endpoint returns both rows and the `groupByHostname` function adds the same metric_id to `failing_metrics` multiple times
|
||||||
|
|
||||||
|
1.2 WHEN the `/items/:hostname` detail endpoint is called for a device that has compliance_items rows across multiple verticals THEN the system returns duplicate metric entries in the `metrics` array because the query has no vertical filter or deduplication
|
||||||
|
|
||||||
|
1.3 WHEN the ComplianceDetailPanel renders the metrics array for a device with duplicate entries THEN the same metric_id chip appears multiple times in the "Failing Metrics" section, confusing users about the actual number of distinct violations
|
||||||
|
|
||||||
|
1.4 WHEN `persistUpload()` builds per-team rows for `compliance_snapshots` and the same hostname has compliance_items rows in both a legacy `vertical IS NULL` upload and an `vertical = 'NTS_AEO'` upload with different statuses (e.g., `active` in one vertical and `resolved` in the other) THEN the snapshot query counts that hostname in BOTH the `compliant` and `non_compliant` columns for the team, inflating per-team totals and producing a row where `compliant + non_compliant > total_devices`
|
||||||
|
|
||||||
|
1.5 WHEN `/vcl/stats` computes the heavy-hitters table and per-team totals and the same hostname has rows in two verticals where the `team` column differs (e.g., `team = 'STEAM'` in the legacy row and `team = 'ACCESS-ENG'` in the NTS_AEO row) THEN the `COUNT(DISTINCT hostname)` aggregate counts the hostname under both team groups, double-counting the device across teams
|
||||||
|
|
||||||
|
1.6 WHEN `/vcl/stats` builds the forecast-burndown for a team by selecting `resolution_date` rows from compliance_items without `DISTINCT` AND the same `(hostname, metric_id)` has duplicate active rows across verticals, both with a non-null `resolution_date` THEN the forecast row count is inflated and the `blockers = teamNonCompliant - forecastItems.length` calculation can go negative or report a misleadingly low blocker count
|
||||||
|
|
||||||
|
1.7 WHEN `/mttr` selects `seen_count, team` from active compliance_items without deduplication AND the same (hostname, metric_id) has duplicate active rows across verticals THEN each duplicate row is bucketed independently in `bucketAgingItems`, inflating per-team aging totals for that team
|
||||||
|
|
||||||
|
### Expected Behavior (Correct)
|
||||||
|
|
||||||
|
2.1 WHEN a device has compliance_items rows for the same (hostname, metric_id) pair across multiple verticals THEN the `/items` endpoint SHALL return only one entry per unique (hostname, metric_id) combination in the `failing_metrics` array, using the row with the highest `seen_count` or most recent `upload_id` as the representative
|
||||||
|
|
||||||
|
2.2 WHEN the `/items/:hostname` detail endpoint is called for a device with rows across multiple verticals THEN the system SHALL return only one metric entry per unique (metric_id, status) combination, preferring the row with the highest `seen_count` or most recent data
|
||||||
|
|
||||||
|
2.3 WHEN the ComplianceDetailPanel renders the metrics for a device THEN each distinct metric_id SHALL appear exactly once in the "Failing Metrics" section regardless of how many underlying compliance_items rows exist for that metric across verticals
|
||||||
|
|
||||||
|
2.4 WHEN `persistUpload()` writes a per-team row to `compliance_snapshots` THEN the system SHALL count each unique hostname at most once across the (compliant, non_compliant) columns for that team, classifying a hostname as `non_compliant` if it has any active row in any vertical for the team and `compliant` only if all of its rows for the team are resolved, so that `compliant + non_compliant ≤ total_devices` always holds
|
||||||
|
|
||||||
|
2.5 WHEN `/vcl/stats` computes heavy-hitters and per-team totals THEN the system SHALL count each unique hostname under exactly one team — the team derived from the most recent (or otherwise canonical) compliance_items row for that hostname across all verticals — so that summing `non_compliant` across teams equals the total non-compliant device count
|
||||||
|
|
||||||
|
2.6 WHEN `/vcl/stats` builds the forecast-burndown for a team THEN the forecast row count SHALL be deduplicated by `(hostname, metric_id)` so that cross-vertical duplicate rows contribute at most one entry per unique violation, and `blockers = teamNonCompliant - dedupedForecastCount` SHALL never be negative
|
||||||
|
|
||||||
|
2.7 WHEN `/mttr` computes aging buckets per team THEN each unique (hostname, metric_id) active violation SHALL be bucketed exactly once using a single representative `seen_count` value, regardless of how many duplicate rows exist across verticals
|
||||||
|
|
||||||
|
### Unchanged Behavior (Regression Prevention)
|
||||||
|
|
||||||
|
3.1 WHEN a device has multiple distinct failing metric_ids (e.g., 7.1.1 and 7.2.1) THEN the system SHALL CONTINUE TO display each distinct metric_id separately in the failing metrics list
|
||||||
|
|
||||||
|
3.2 WHEN a device has both active and resolved entries for the same metric_id THEN the system SHALL CONTINUE TO show the metric in the appropriate section (active or resolved) based on its status
|
||||||
|
|
||||||
|
3.3 WHEN only one compliance upload exists per vertical for a device (no cross-vertical duplication) THEN the system SHALL CONTINUE TO display metrics unchanged with correct seen_count, first_seen, and last_seen values
|
||||||
|
|
||||||
|
3.4 WHEN the `/items` list endpoint is called with a team filter THEN the system SHALL CONTINUE TO return all devices for that team with their correct (now deduplicated) failing metrics and accurate seen_count values
|
||||||
|
|
||||||
|
3.5 WHEN `persistUpload()` builds `compliance_snapshots` for a team whose devices exist in only one vertical THEN per-team `total_devices`, `compliant`, `non_compliant`, and `compliance_pct` SHALL CONTINUE TO match their pre-fix values
|
||||||
|
|
||||||
|
3.6 WHEN `/vcl/stats` computes overall stats, donut categorization, heavy-hitters, per-team totals, and forecast-burndown for devices that exist in only one vertical THEN every field in the response SHALL CONTINUE TO match its pre-fix value
|
||||||
|
|
||||||
|
3.7 WHEN `/mttr` computes aging buckets for teams whose active items exist in only one vertical THEN per-team and total bucket counts SHALL CONTINUE TO match their pre-fix values
|
||||||
511
.kiro/specs/compliance-duplicate-failing-metrics/design.md
Normal file
511
.kiro/specs/compliance-duplicate-failing-metrics/design.md
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
# Compliance Duplicate Failing Metrics Bugfix Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Five compliance endpoints in `backend/routes/compliance.js` (`GET /items`, `GET /items/:hostname`, `GET /vcl/stats`, `GET /mttr`) and the `compliance_snapshots` block inside `persistUpload()` all share the same root cause: each one reads `compliance_items` with either no vertical filter or a filter that admits both legacy `vertical IS NULL` rows and multi-vertical `vertical = 'NTS_AEO'` rows, but does not deduplicate on `(hostname, metric_id)`. When the same `(hostname, metric_id)` pair exists in two verticals (the documented multi-vertical workflow), the duplicate row distorts the response — it duplicates a metric chip in the UI, double-counts a device across teams, inflates aging buckets, inflates forecast row counts, and (in the snapshot block) lets a single hostname appear in both `compliant` and `non_compliant` columns of the same `(snapshot_month, vertical)` row.
|
||||||
|
|
||||||
|
The fix is uniform across endpoints: dedupe at the SQL layer using `DISTINCT ON (hostname, metric_id)` with a deterministic `ORDER BY` (highest `seen_count`, then most recent `upload_id`) so each unique violation contributes exactly one row to the aggregation, and rewrite the snapshot query so each hostname is classified by its `MIN(status)` (active wins over resolved) inside a CTE before the count. The `groupByHostname` helper and the `/items/:hostname` response builder also gain a defensive in-memory dedupe keyed by `metric_id` (or `(metric_id, status)` for the detail endpoint), so a duplicate row that slips through any unforeseen code path still cannot duplicate a chip in the UI.
|
||||||
|
|
||||||
|
The implementation is intentionally minimal: each fix changes a single SQL statement and (for two endpoints) a small JavaScript loop. No schema migration, no new column, no frontend change. The frontend `ComplianceDetailPanel` already keys metrics on `metric_id` for chip rendering and will render correctly once the API stops returning duplicate rows.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Bug_Condition (C)**: The condition that triggers the bug — two or more rows in `compliance_items` share the same `(hostname, metric_id)` pair across verticals (i.e., one row with `vertical IS NULL` and another with `vertical = 'NTS_AEO'`, or two non-null verticals that both pass an endpoint's `WHERE` clause).
|
||||||
|
- **Property (P)**: The desired behavior when C holds — each affected endpoint returns or counts each `(hostname, metric_id)` exactly once, using the row with the highest `seen_count` (with most recent `upload_id` as tiebreak) as the representative.
|
||||||
|
- **Preservation**: Behavior on rows where `(hostname, metric_id)` is unique across verticals, on the empty-data response shape, and on unrelated query parameters (e.g., `team` and `status` on `/items`) — all must be byte-for-byte unchanged.
|
||||||
|
- **vertical**: `TEXT` column on `compliance_items` and `compliance_uploads` identifying which xlsx (NTS_AEO, SDIT_CISO, TSI) the row originated from. `NULL` indicates a legacy AEO-only upload. The bug class is specifically about rows that share `(hostname, metric_id)` but differ in `vertical`.
|
||||||
|
- **groupByHostname(rows, noteHostnames)**: Helper in `backend/routes/compliance.js` (lines ~213–230) that flattens a list of joined `compliance_items` rows into one device object per hostname, pushing each row's `metric_id` onto `failing_metrics`. It performs no deduplication.
|
||||||
|
- **bucketAgingItems(items)**: Helper in `backend/routes/compliance.js` (lines ~234–254) that places each item into one of four `seen_count` buckets per team. It iterates rows directly, so duplicate rows produce double-counted buckets.
|
||||||
|
- **persistUpload()**: Function in `backend/routes/compliance.js` (lines ~81–192) that writes a parsed upload to the DB and then writes per-vertical rows into `compliance_snapshots`. The snapshot query at the end of this function counts hostnames per team using `COUNT(DISTINCT CASE WHEN status = 'X' THEN hostname END)`, which double-counts a hostname into both the `compliant` and `non_compliant` columns when the hostname has both `active` and `resolved` rows across verticals.
|
||||||
|
- **representative row**: For a duplicated `(hostname, metric_id)`, the row chosen by `DISTINCT ON (hostname, metric_id) ... ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC`. This is deterministic and aligns with the "use the row with the highest `seen_count` or most recent `upload_id`" rule from the requirements.
|
||||||
|
|
||||||
|
## Bug Details
|
||||||
|
|
||||||
|
### Bug Condition
|
||||||
|
|
||||||
|
The bug manifests when two or more rows in `compliance_items` share the same `(hostname, metric_id)` pair across different `vertical` values. This happens whenever the same device fails the same metric in more than one vertical's xlsx — including the originally reported case where a legacy `vertical IS NULL` AEO upload and a newer `vertical = 'NTS_AEO'` multi-vertical upload both contain `STEAM-INTERSIGHT` failing `7.1.1`.
|
||||||
|
|
||||||
|
**Formal Specification:**
|
||||||
|
```
|
||||||
|
FUNCTION isBugCondition(items)
|
||||||
|
INPUT: items — list of compliance_items rows
|
||||||
|
OUTPUT: boolean
|
||||||
|
|
||||||
|
// The bug condition is triggered for any (hostname, metric_id) pair with more than one row
|
||||||
|
GROUP items BY (hostname, metric_id) INTO groups
|
||||||
|
RETURN EXISTS group IN groups WHERE COUNT(group) > 1
|
||||||
|
END FUNCTION
|
||||||
|
```
|
||||||
|
|
||||||
|
For a single endpoint response to be considered buggy, the API output must additionally fail one of the following invariants (the per-endpoint manifestation of the same root cause):
|
||||||
|
|
||||||
|
```
|
||||||
|
FUNCTION isBuggyResponse(endpoint, response)
|
||||||
|
CASE endpoint OF
|
||||||
|
'/items': RETURN EXISTS device IN response.devices WHERE
|
||||||
|
COUNT(device.failing_metrics) != COUNT(DISTINCT metric_id IN device.failing_metrics)
|
||||||
|
'/items/:hostname': RETURN EXISTS (metric_id, status) WITH COUNT(*) > 1 IN response.metrics
|
||||||
|
'/vcl/stats heavy_hitters': RETURN SUM(hh.non_compliant FOR hh IN response.heavy_hitters) >
|
||||||
|
COUNT(DISTINCT hostname WHERE has_active_violation)
|
||||||
|
'/vcl/stats forecast': RETURN response.vertical_breakdown[i].blockers < 0
|
||||||
|
FOR SOME i WITH duplicated (hostname, metric_id) AND non-null resolution_date
|
||||||
|
'/mttr': RETURN SUM(b.total FOR b IN response.aging) >
|
||||||
|
COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')
|
||||||
|
'persistUpload snapshot': RETURN EXISTS row IN compliance_snapshots WHERE
|
||||||
|
compliant + non_compliant > total_devices
|
||||||
|
END CASE
|
||||||
|
END FUNCTION
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
The originally reported case (GitLab issue #13, `STEAM-INTERSIGHT` / 172.16.30.40 / metric `7.1.1`) and the four sibling manifestations:
|
||||||
|
|
||||||
|
- **`/items`** — `STEAM-INTERSIGHT` has two active rows for `metric_id = '7.1.1'` (one with `vertical IS NULL`, one with `vertical = 'NTS_AEO'`). The handler's `WHERE` clause admits both via `(ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')`, and `groupByHostname()` does an unconditional `dev.failing_metrics.push(...)` per row. The device's `failing_metrics` array contains two entries with `metric_id = '7.1.1'`. Expected: one entry per unique `metric_id`.
|
||||||
|
|
||||||
|
- **`/items/:hostname`** — Same hostname, but the detail query has no vertical filter at all (just `WHERE ci.hostname = $1`). Both rows come back, the response builder maps `metricRows.map(...)` over both, and the frontend's `MetricChip` renders `7.1.1` twice in the "Failing Metrics" section. Expected: one entry per `(metric_id, status)` pair.
|
||||||
|
|
||||||
|
- **`/vcl/stats` heavy-hitters and per-team totals** — A device whose `team` differs between verticals (e.g., `STEAM` in the legacy row and `ACCESS-ENG` in the NTS_AEO row) is counted under both teams by `COUNT(DISTINCT hostname) ... GROUP BY team`. The sum across `heavy_hitters[*].non_compliant` exceeds the dashboard's `stats.non_compliant`. Expected: the hostname is assigned to exactly one team — the team from its representative row — and the per-team sums reconcile with the global non-compliant count.
|
||||||
|
|
||||||
|
- **`/vcl/stats` forecast-burndown** — For a team with one duplicated `(hostname, metric_id)` whose `resolution_date` is non-null in both rows, the forecast query `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND team = $1 AND resolution_date IS NOT NULL` returns two rows. `forecastItems.length` is 2, but `teamNonCompliant` (from the de-team-counted DISTINCT-hostname query) is 1, so `blockers = 1 - 2 = -1`. The route then clamps to 0, hiding the underlying inconsistency. Expected: forecast is deduped by `(hostname, metric_id)` so the count matches `teamNonCompliant`'s scoping and `blockers` is non-negative.
|
||||||
|
|
||||||
|
- **`/mttr`** — Same duplicated `(hostname, metric_id)`. `bucketAgingItems()` receives both rows, increments the bucket twice. The team total for `STEAM` (or whichever team appears in the duplicate row) is inflated. Expected: each unique `(hostname, metric_id)` contributes to exactly one bucket using a single representative `seen_count`.
|
||||||
|
|
||||||
|
- **Edge case — `persistUpload()` snapshot** — A hostname has two rows: one with `status = 'resolved'` (legacy vertical) and one with `status = 'active'` (NTS_AEO vertical). The snapshot query
|
||||||
|
```sql
|
||||||
|
COUNT(DISTINCT CASE WHEN status = 'resolved' THEN hostname END) AS compliant,
|
||||||
|
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END) AS non_compliant
|
||||||
|
```
|
||||||
|
counts the hostname once in `compliant` and once in `non_compliant`, so `compliant + non_compliant > total_devices`. Expected: the hostname is classified by its worst-case status (active wins over resolved) inside a CTE so it appears in exactly one column.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
### Preservation Requirements
|
||||||
|
|
||||||
|
**Unchanged Behaviors:**
|
||||||
|
- Rows where `(hostname, metric_id)` is unique across verticals: every endpoint returns the same numbers, in the same shape, in the same order as before the fix.
|
||||||
|
- Empty-data responses: `/items` returns `{ devices: [], team, status }`, `/items/:hostname` returns `404` for unknown hostnames, `/vcl/stats` returns its zero-state shape, `/mttr` returns `{ aging: [] }`.
|
||||||
|
- `/items` `team` and `status` query parameters: still validated against `ALLOWED_TEAMS` and `['active', 'resolved']`, still reject invalid values with HTTP 400.
|
||||||
|
- `/items` `(ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')` predicate: kept as-is so AEO-only legacy data continues to surface alongside NTS_AEO multi-vertical data. The fix only adds dedup on top, it does not narrow the set of admitted verticals.
|
||||||
|
- `/items/:hostname` ordering by `status DESC, metric_id`: unchanged so `active` metrics remain listed before `resolved` ones.
|
||||||
|
- `/vcl/stats` donut categorization (`blocked` / `in_progress` by `MAX(resolution_date)` per hostname): already deduped by `GROUP BY hostname` and stays unchanged.
|
||||||
|
- `/vcl/stats` global `stats.compliant` / `stats.non_compliant`: already use `COUNT(DISTINCT hostname)` and stay unchanged.
|
||||||
|
- Frontend components (`ComplianceDetailPanel`, `ComplianceCharts`, `CompliancePage`): no changes required. They already render one chip per `metric_id`; the bug was purely an upstream data issue.
|
||||||
|
- `persistUpload()` error handling: snapshot creation remains wrapped in `try/catch` that logs but does not fail the upload commit.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
All endpoint inputs that do not involve cross-vertical duplicate `(hostname, metric_id)` rows must be byte-for-byte identical to the pre-fix output. The fix only changes what happens when two or more `compliance_items` rows share `(hostname, metric_id)` across verticals.
|
||||||
|
|
||||||
|
## Hypothesized Root Cause
|
||||||
|
|
||||||
|
All five sites have the same shape of bug — missing dedup on `(hostname, metric_id)` after the multi-vertical migration admitted two-row scenarios — but with slightly different mechanics. Listing them explicitly so the test plan can confirm or refute each one:
|
||||||
|
|
||||||
|
1. **`/items` — `groupByHostname()` pushes every row.** The handler's `WHERE` clause `(ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')` admits both verticals' rows. `groupByHostname()` then iterates each row and unconditionally calls `dev.failing_metrics.push({ metric_id, ... })`. There is no `Set`/`Map` keyed on `metric_id`, so a duplicate row produces a duplicate metric in the array.
|
||||||
|
|
||||||
|
2. **`/items/:hostname` — no vertical filter, no dedup.** The detail query is `WHERE ci.hostname = $1` with no `vertical` predicate at all, so every vertical's row for that hostname is returned. The response builder does `metricRows.map(r => ({ ...r }))` — there is no dedup step. Every duplicate row becomes a duplicate entry in `metrics`.
|
||||||
|
|
||||||
|
3. **`/vcl/stats` heavy-hitters and per-team totals — team chosen per row, not per hostname.** The `GROUP BY team ... COUNT(DISTINCT hostname)` query is correct for "how many distinct hostnames does each team see," but a hostname with rows under two different teams (because `team` differs across verticals) is counted in both groups. The dashboard's global `stats.non_compliant` (a single `COUNT(DISTINCT hostname)` with no team scoping) does not match `SUM(heavy_hitters[*].non_compliant)`.
|
||||||
|
|
||||||
|
4. **`/vcl/stats` forecast-burndown — duplicate rows inflate forecast count.** The query `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND team = $1 AND resolution_date IS NOT NULL` returns one row per `compliance_items` row, not one row per `(hostname, metric_id)`. The downstream `blockers = teamNonCompliant - forecastItems.length` calculation can go negative because `forecastItems.length` is inflated relative to the deduped `teamNonCompliant`.
|
||||||
|
|
||||||
|
5. **`/mttr` — `bucketAgingItems()` iterates rows directly.** The query `SELECT seen_count, team FROM compliance_items WHERE status = 'active'` returns every active row, and `bucketAgingItems()` does `for (const item of items) { buckets[label].total += 1; ... }`. There is no `Set` keyed on `(hostname, metric_id)`, so each duplicate row increments its bucket twice.
|
||||||
|
|
||||||
|
6. **`persistUpload()` snapshot — `CASE WHEN status =` double-counts.** The snapshot query
|
||||||
|
```sql
|
||||||
|
COUNT(DISTINCT CASE WHEN status = 'resolved' THEN hostname END) AS compliant,
|
||||||
|
COUNT(DISTINCT CASE WHEN status = 'active' THEN hostname END) AS non_compliant
|
||||||
|
```
|
||||||
|
counts a hostname in `compliant` if any of its rows is `resolved` AND in `non_compliant` if any of its rows is `active`. With duplicate rows that disagree on status across verticals, the same hostname lands in both columns.
|
||||||
|
|
||||||
|
The common structural cause is that the multi-vertical migration (`add_vcl_multi_vertical.js`) added a `vertical` column to `compliance_items` but did not retrofit existing read queries — which assumed `(hostname, metric_id)` was effectively a unique key — to either dedupe explicitly or scope to a single vertical.
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
Property 1: Bug Condition — `/items` failing_metrics array contains at most one entry per metric_id
|
||||||
|
|
||||||
|
_For any_ set of `compliance_items` rows including cross-vertical duplicates of `(hostname, metric_id)`, the response from `GET /items` SHALL contain, for every device in `response.devices`, exactly one entry per unique `metric_id` in `device.failing_metrics`. Formally: `device.failing_metrics.length == new Set(device.failing_metrics.map(m => m.metric_id)).size`.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.1**
|
||||||
|
|
||||||
|
Property 2: Bug Condition — `/items/:hostname` returns one metric per (metric_id, status)
|
||||||
|
|
||||||
|
_For any_ device with cross-vertical duplicate `(hostname, metric_id)` rows, the response from `GET /items/:hostname` SHALL contain exactly one entry per unique `(metric_id, status)` pair in `response.metrics`. Each entry's `seen_count` SHALL equal the maximum `seen_count` across the duplicate rows for that pair.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.2, 2.3**
|
||||||
|
|
||||||
|
Property 3: Bug Condition — `/vcl/stats` per-team device counts equal COUNT(DISTINCT hostname)
|
||||||
|
|
||||||
|
_For any_ set of `compliance_items` rows including cross-vertical duplicates where a hostname's `team` differs across verticals, the response from `GET /vcl/stats` SHALL satisfy `SUM(heavy_hitters[*].non_compliant) == stats.non_compliant`. Each hostname SHALL be assigned to exactly one team — the team from its representative row (highest `seen_count`, then most recent `upload_id`) — regardless of how many verticals contain rows for that hostname.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.5**
|
||||||
|
|
||||||
|
Property 4: Bug Condition — `/vcl/stats` forecast-burndown is deduped by (hostname, metric_id) and blockers is non-negative
|
||||||
|
|
||||||
|
_For any_ team with cross-vertical duplicate `(hostname, metric_id)` rows where both rows have a non-null `resolution_date`, the deduped forecast row count for that team SHALL contribute at most one entry per unique `(hostname, metric_id)`, and `blockers = teamNonCompliant - dedupedForecastCount` SHALL be `>= 0` (no clamp required to satisfy the invariant).
|
||||||
|
|
||||||
|
**Validates: Requirements 2.6**
|
||||||
|
|
||||||
|
Property 5: Bug Condition — `/mttr` aging buckets count each unique (hostname, metric_id) exactly once
|
||||||
|
|
||||||
|
_For any_ set of active `compliance_items` rows including cross-vertical duplicates, the response from `GET /mttr` SHALL satisfy `SUM(aging[*].total) == COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')`. Each unique `(hostname, metric_id)` SHALL be bucketed exactly once using a single representative `seen_count`.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.7**
|
||||||
|
|
||||||
|
Property 6: Bug Condition — `persistUpload()` snapshot rows satisfy compliant + non_compliant <= total_devices
|
||||||
|
|
||||||
|
_For any_ `persistUpload()` invocation, every row written into `compliance_snapshots` for the current `(snapshot_month, vertical)` pair SHALL satisfy `compliant + non_compliant <= total_devices`. A hostname with both `active` and `resolved` rows for the same team SHALL be classified as `non_compliant` (active wins over resolved) and SHALL appear in exactly one of the two columns.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.4**
|
||||||
|
|
||||||
|
Property 7: Preservation — non-duplicated rows are unchanged
|
||||||
|
|
||||||
|
_For any_ set of `compliance_items` rows where every `(hostname, metric_id)` is unique across verticals, the responses from `/items`, `/items/:hostname`, `/vcl/stats`, `/mttr`, and the `compliance_snapshots` rows written by `persistUpload()` SHALL be identical to the pre-fix output for the same input. The fix SHALL NOT change behavior on the unique-`(hostname, metric_id)` case.
|
||||||
|
|
||||||
|
**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7**
|
||||||
|
|
||||||
|
Property 8: Preservation — `seen_count`, `first_seen`, and `last_seen` are correctly aggregated for the representative row
|
||||||
|
|
||||||
|
_For any_ duplicated `(hostname, metric_id)`, the surviving entry SHALL carry `seen_count = MAX(seen_count)` across the duplicates, `first_seen = MIN(first_seen)` across the duplicates, and `last_seen = MAX(last_seen)` across the duplicates. Active/resolved status separation SHALL be preserved (a metric still appears in the `active` section if any of its duplicate rows is `active`).
|
||||||
|
|
||||||
|
**Validates: Requirements 3.2, 3.3**
|
||||||
|
|
||||||
|
## Fix Implementation
|
||||||
|
|
||||||
|
### Changes Required
|
||||||
|
|
||||||
|
All changes are in `backend/routes/compliance.js`. No schema migration, no new column, no frontend change. SQL-level dedup is preferred wherever possible because it avoids materialising duplicates into Node memory and centralises the representative-row policy at the data layer.
|
||||||
|
|
||||||
|
#### Fix 1: `GET /items` — `DISTINCT ON (hostname, metric_id)` in SQL, defensive dedupe in `groupByHostname`
|
||||||
|
|
||||||
|
**File**: `backend/routes/compliance.js`
|
||||||
|
|
||||||
|
**Function**: `router.get('/items', ...)` (around line 535) and helper `groupByHostname` (around line 213)
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Rewrite the items query to use `DISTINCT ON (hostname, metric_id)` so each unique violation contributes exactly one row, choosing the representative row by highest `seen_count` and most recent `upload_id`:
|
||||||
|
```sql
|
||||||
|
SELECT DISTINCT ON (ci.hostname, ci.metric_id)
|
||||||
|
ci.hostname, ci.ip_address, ci.device_type, ci.team,
|
||||||
|
ci.metric_id, ci.metric_desc, ci.category, ci.status, ci.seen_count,
|
||||||
|
fu.report_date AS first_seen,
|
||||||
|
lu.report_date AS last_seen,
|
||||||
|
ru.report_date AS resolved_on
|
||||||
|
FROM compliance_items ci
|
||||||
|
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
|
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||||
|
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
|
WHERE ci.team = $1
|
||||||
|
AND ci.status = $2
|
||||||
|
AND (ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')
|
||||||
|
ORDER BY ci.hostname, ci.metric_id, ci.seen_count DESC, ci.upload_id DESC
|
||||||
|
```
|
||||||
|
The `ORDER BY` lead expressions match the `DISTINCT ON` columns; the trailing expressions select the representative.
|
||||||
|
2. Add a defensive in-memory dedupe in `groupByHostname()` keyed on `metric_id` per device, so any future code path that bypasses the SQL dedupe still cannot duplicate a chip:
|
||||||
|
```javascript
|
||||||
|
function groupByHostname(rows, noteHostnames) {
|
||||||
|
const deviceMap = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!deviceMap[row.hostname]) {
|
||||||
|
deviceMap[row.hostname] = {
|
||||||
|
hostname: row.hostname, ip_address: row.ip_address || '', device_type: row.device_type || '',
|
||||||
|
team: row.team || '', status: row.status, failing_metrics: [],
|
||||||
|
_seenMetricIds: new Set(),
|
||||||
|
seen_count: row.seen_count || 1, first_seen: row.first_seen || null,
|
||||||
|
last_seen: row.last_seen || null, resolved_on: row.resolved_on || null,
|
||||||
|
has_notes: noteHostnames.has(row.hostname),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const dev = deviceMap[row.hostname];
|
||||||
|
if (!dev._seenMetricIds.has(row.metric_id)) {
|
||||||
|
dev._seenMetricIds.add(row.metric_id);
|
||||||
|
dev.failing_metrics.push({ metric_id: row.metric_id, metric_desc: row.metric_desc || '', category: row.category || '' });
|
||||||
|
}
|
||||||
|
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
|
||||||
|
if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) dev.first_seen = row.first_seen;
|
||||||
|
if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) dev.last_seen = row.last_seen;
|
||||||
|
}
|
||||||
|
return Object.values(deviceMap).map(({ _seenMetricIds, ...dev }) => dev);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The `_seenMetricIds` set is stripped before the device is returned so the response shape is unchanged.
|
||||||
|
|
||||||
|
#### Fix 2: `GET /items/:hostname` — `DISTINCT ON (metric_id, status)` in SQL
|
||||||
|
|
||||||
|
**File**: `backend/routes/compliance.js`
|
||||||
|
|
||||||
|
**Function**: `router.get('/items/:hostname', ...)` (around line 575)
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Rewrite the metrics query to dedupe on `(metric_id, status)` so a metric that appears as both `active` and `resolved` is preserved (one row each), but cross-vertical duplicates of the same `(metric_id, status)` collapse to one:
|
||||||
|
```sql
|
||||||
|
SELECT DISTINCT ON (ci.metric_id, ci.status)
|
||||||
|
ci.metric_id, ci.metric_desc, ci.category, ci.status,
|
||||||
|
ci.ip_address, ci.device_type, ci.team, ci.seen_count, ci.extra_json,
|
||||||
|
ci.resolution_date, ci.remediation_plan,
|
||||||
|
fu.report_date AS first_seen, fu.uploaded_at AS first_seen_at,
|
||||||
|
lu.report_date AS last_seen, lu.uploaded_at AS last_seen_at,
|
||||||
|
ru.report_date AS resolved_on
|
||||||
|
FROM compliance_items ci
|
||||||
|
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||||
|
LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
|
||||||
|
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||||
|
WHERE ci.hostname = $1
|
||||||
|
ORDER BY ci.metric_id, ci.status, ci.seen_count DESC, ci.upload_id DESC
|
||||||
|
```
|
||||||
|
2. The existing post-query sort (`status DESC` for grouping, then `metric_id`) is rebuilt in JavaScript on the deduped result to preserve the response ordering:
|
||||||
|
```javascript
|
||||||
|
metricRows.sort((a, b) => {
|
||||||
|
if (a.status !== b.status) return b.status.localeCompare(a.status); // 'resolved' < 'active' alphabetically; we want 'active' first
|
||||||
|
return a.metric_id.localeCompare(b.metric_id);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
(Note: the original SQL used `ORDER BY ci.status DESC, ci.metric_id`, which placed `resolved` before `active` because of `DESC` on the alphabetic order. The post-fix sort reproduces that exact ordering on the deduped rows.)
|
||||||
|
3. The `identity` lookup `metricRows.find(r => r.status === 'active') || metricRows[0]` is unchanged — it still picks the active representative for `ip_address`, `device_type`, `team`, `resolution_date`, and `remediation_plan`.
|
||||||
|
|
||||||
|
#### Fix 3: `GET /vcl/stats` heavy-hitters and per-team totals — dedupe to one team per hostname via CTE
|
||||||
|
|
||||||
|
**File**: `backend/routes/compliance.js`
|
||||||
|
|
||||||
|
**Function**: `router.get('/vcl/stats', ...)` (around line 990, the `teamRows` query)
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Replace the heavy-hitters query with a CTE that first picks one representative row per hostname (using the same `DISTINCT ON (hostname)` policy: highest `seen_count`, then most recent `upload_id`), then groups by the representative's team:
|
||||||
|
```sql
|
||||||
|
WITH device_team AS (
|
||||||
|
SELECT DISTINCT ON (hostname)
|
||||||
|
hostname,
|
||||||
|
COALESCE(team, 'Unknown') AS team,
|
||||||
|
resolution_date
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY hostname, seen_count DESC, upload_id DESC
|
||||||
|
)
|
||||||
|
SELECT team,
|
||||||
|
COUNT(DISTINCT hostname)::int AS non_compliant,
|
||||||
|
MAX(resolution_date) AS compliance_date
|
||||||
|
FROM device_team
|
||||||
|
GROUP BY team
|
||||||
|
ORDER BY COUNT(DISTINCT hostname) DESC
|
||||||
|
```
|
||||||
|
Because the CTE already collapses each hostname to one row, `COUNT(DISTINCT hostname)` per team is equivalent to `COUNT(*)` per team, but `DISTINCT` is kept for defensive symmetry.
|
||||||
|
2. The dashboard's existing `stats.non_compliant` query (also `COUNT(DISTINCT hostname)` over all active rows, no team scoping) is unchanged — but now the property `SUM(heavy_hitters[*].non_compliant) == stats.non_compliant` holds because both numerators and denominators agree on "one hostname, one team."
|
||||||
|
3. The per-team-total query inside the `for (const teamRow of teamRows)` loop (`SELECT COUNT(DISTINCT hostname) AS total FROM compliance_items WHERE COALESCE(team, 'Unknown') = $1`) is similarly rewritten to use the same `device_team`-style CTE so the team's `total_devices` matches the team's `non_compliant`. The simplest form:
|
||||||
|
```sql
|
||||||
|
WITH device_team AS (
|
||||||
|
SELECT DISTINCT ON (hostname)
|
||||||
|
hostname,
|
||||||
|
COALESCE(team, 'Unknown') AS team
|
||||||
|
FROM compliance_items
|
||||||
|
ORDER BY hostname, seen_count DESC, upload_id DESC
|
||||||
|
)
|
||||||
|
SELECT COUNT(*)::int AS total FROM device_team WHERE team = $1
|
||||||
|
```
|
||||||
|
Note: the loop runs once per team, so this CTE is computed N times — acceptable given the small team count (4 teams). If profiling shows this is a hot path, hoist the `device_team` CTE out of the loop and compute totals in a single grouped query.
|
||||||
|
|
||||||
|
#### Fix 4: `GET /vcl/stats` forecast-burndown — dedupe by `(hostname, metric_id)` in SQL
|
||||||
|
|
||||||
|
**File**: `backend/routes/compliance.js`
|
||||||
|
|
||||||
|
**Function**: `router.get('/vcl/stats', ...)` (around line 1015, the `forecastItems` query)
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Rewrite the forecast query to `DISTINCT ON (hostname, metric_id)` so each unique violation contributes at most one `resolution_date` to the forecast bucketing:
|
||||||
|
```sql
|
||||||
|
SELECT DISTINCT ON (hostname, metric_id) resolution_date
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND COALESCE(team, 'Unknown') = $1
|
||||||
|
AND resolution_date IS NOT NULL
|
||||||
|
ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC
|
||||||
|
```
|
||||||
|
2. The downstream `blockers = teamNonCompliant - forecastItems.length` calculation is unchanged. With both sides now deduped consistently — `teamNonCompliant` from the `device_team` CTE in Fix 3, `forecastItems.length` from the deduped query above — the difference is non-negative without any clamp.
|
||||||
|
3. The `Math.max(blockers, 0)` clamp at the existing call site is left in place as a belt-and-braces safeguard. It SHOULD be a no-op after the fix; if a regression introduces inconsistency again, the property test for Property 4 will catch it.
|
||||||
|
|
||||||
|
#### Fix 5: `GET /mttr` — dedupe by `(hostname, metric_id)` in SQL
|
||||||
|
|
||||||
|
**File**: `backend/routes/compliance.js`
|
||||||
|
|
||||||
|
**Function**: `router.get('/mttr', ...)` (around line 824)
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Rewrite the query to `DISTINCT ON (hostname, metric_id)` so each unique active violation contributes exactly one `(seen_count, team)` row to `bucketAgingItems()`:
|
||||||
|
```sql
|
||||||
|
SELECT DISTINCT ON (hostname, metric_id)
|
||||||
|
COALESCE(seen_count, 1) AS seen_count,
|
||||||
|
team
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC
|
||||||
|
```
|
||||||
|
2. `bucketAgingItems()` itself does not change. It already does the right thing when fed one row per unique violation.
|
||||||
|
3. SQL-level dedup is preferred over a JavaScript-side `Set` here because the helper is also called from `/vcl/stats` and changing its contract risks regressions in the other caller. Pushing the dedup into SQL keeps `bucketAgingItems()` pure and reusable.
|
||||||
|
|
||||||
|
#### Fix 6: `persistUpload()` snapshot block — classify hostnames via `MIN(status)` in a CTE
|
||||||
|
|
||||||
|
**File**: `backend/routes/compliance.js`
|
||||||
|
|
||||||
|
**Function**: `persistUpload()` (lines 81–192), specifically the `verticalStats` query at line 157
|
||||||
|
|
||||||
|
**Specific Changes**:
|
||||||
|
1. Rewrite the snapshot query so each hostname is classified by its worst-case status (active wins over resolved) inside a CTE before counting. Because `'active' < 'resolved'` lexicographically, `MIN(status)` returns `'active'` for a hostname that has any active row and `'resolved'` only if all rows are `'resolved'`:
|
||||||
|
```sql
|
||||||
|
WITH hostname_status AS (
|
||||||
|
SELECT team,
|
||||||
|
hostname,
|
||||||
|
MIN(status) AS status -- 'active' beats 'resolved' alphabetically
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE team IS NOT NULL
|
||||||
|
GROUP BY team, hostname
|
||||||
|
)
|
||||||
|
SELECT team AS vertical,
|
||||||
|
COUNT(*)::int AS total_devices,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'resolved')::int AS compliant,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'active')::int AS non_compliant
|
||||||
|
FROM hostname_status
|
||||||
|
GROUP BY team
|
||||||
|
```
|
||||||
|
Each hostname appears exactly once in `hostname_status`, so `compliant + non_compliant == total_devices` is structurally guaranteed.
|
||||||
|
2. The downstream `INSERT ... ON CONFLICT (snapshot_month, vertical) DO UPDATE` block is unchanged. It already keys snapshots on `vertical`, and the per-team counts now satisfy `compliant + non_compliant <= total_devices`.
|
||||||
|
3. The `compliance_pct` calculation `Math.round((vs.compliant / total) * 100 * 100) / 100` is unchanged. It is now defended against the previous off-by-one: with the old query a hostname could be in both `compliant` and `non_compliant`, double-counting itself in `compliant`'s numerator while only contributing once to `total_devices`'s denominator. The fix removes that.
|
||||||
|
|
||||||
|
> Note: Fix 6 is conceptually adjacent to but mechanically distinct from the `persistUpload` fix in `compliance-duplicate-chart-entries`. That spec adds a `WHERE vertical = $1` filter to scope the snapshot to one vertical at a time. This spec adds the `hostname_status` CTE so that within whichever vertical is snapshotted, each hostname is classified once. Both fixes are independently necessary; landing them together is preferred but each is correct on its own.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Validation Approach
|
||||||
|
|
||||||
|
The bug condition is straightforward to construct: insert two `compliance_items` rows for the same `(hostname, metric_id)` with different `vertical` values, then call each affected endpoint. The two-phase approach is to first run the tests against the unfixed code to confirm the duplication counterexamples, then run the same tests against the fixed code and add property-based tests that explore the input space more broadly.
|
||||||
|
|
||||||
|
### Exploratory Bug Condition Checking
|
||||||
|
|
||||||
|
**Goal**: Surface counterexamples that demonstrate each of the six manifestations BEFORE implementing the fix. Confirm or refute the root cause analysis for each endpoint independently — they share a structural cause but the SQL details differ.
|
||||||
|
|
||||||
|
**Test Plan**: Seed a clean test database with a fixture representing the original GitLab #13 scenario (`STEAM-INTERSIGHT` failing `7.1.1` in both `vertical IS NULL` and `vertical = 'NTS_AEO'`), plus targeted variants for each sibling endpoint. Call each affected endpoint and assert the buggy invariants. Run on UNFIXED code first.
|
||||||
|
|
||||||
|
**Test Cases**:
|
||||||
|
|
||||||
|
1. **`/items` Duplicate Failing Metric Test** — Insert two rows for `(STEAM-INTERSIGHT, 7.1.1)`: one with `vertical IS NULL, status = 'active', team = 'STEAM'`, another with `vertical = 'NTS_AEO', status = 'active', team = 'STEAM'`. Call `GET /items?team=STEAM&status=active`. Assert that `response.devices[0].failing_metrics.filter(m => m.metric_id === '7.1.1').length === 1`. (will fail on unfixed code — returns 2)
|
||||||
|
|
||||||
|
2. **`/items/:hostname` Duplicate Metric Entry Test** — Same fixture. Call `GET /items/STEAM-INTERSIGHT`. Assert `response.metrics.filter(m => m.metric_id === '7.1.1' && m.status === 'active').length === 1`. Assert `response.metrics[0].seen_count === MAX(seen_count across the duplicate rows)`. (will fail on unfixed code — returns 2)
|
||||||
|
|
||||||
|
3. **`/vcl/stats` Cross-Team Hostname Test** — Insert two rows for `(SOME-DEVICE, 7.1.1)`: one with `team = 'STEAM', vertical IS NULL`, another with `team = 'ACCESS-ENG', vertical = 'NTS_AEO'`. Call `GET /vcl/stats`. Assert `SUM(heavy_hitters[*].non_compliant) === stats.non_compliant`. (will fail on unfixed code — sum exceeds the global count by 1)
|
||||||
|
|
||||||
|
4. **`/vcl/stats` Forecast Negative Blockers Test** — Insert two rows for `(SOME-DEVICE, 7.1.1)` both with `team = 'STEAM', status = 'active'`, both with `resolution_date = '2025-09-30'`, but different verticals. Call `GET /vcl/stats`. Assert `vertical_breakdown.find(v => v.team === 'STEAM').blockers >= 0` AND that the unclamped `teamNonCompliant - dedupedForecastCount` equals the reported `blockers`. (will fail on unfixed code — clamped from `-1` to `0`, hiding the inconsistency; the unclamped check makes the failure visible)
|
||||||
|
|
||||||
|
5. **`/mttr` Inflated Bucket Test** — Insert two rows for `(SOME-DEVICE, 7.1.1)` with `seen_count = 5, team = 'STEAM', status = 'active'`, different verticals. Compare `SUM(aging[*].total)` to `COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')`. Assert equality. (will fail on unfixed code — bucket total exceeds distinct count by 1)
|
||||||
|
|
||||||
|
6. **`persistUpload()` Snapshot Inconsistency Test** — Pre-populate `compliance_items` with `(SOME-DEVICE, 7.1.1)` having one `status = 'active'` row and one `status = 'resolved'` row, both `team = 'STEAM'`, different verticals. Call `persistUpload()` with a no-op upload. Read back the `compliance_snapshots` row for the current month and `STEAM`. Assert `compliant + non_compliant <= total_devices`. (will fail on unfixed code — the hostname is counted in both columns so the sum exceeds `total_devices` by 1)
|
||||||
|
|
||||||
|
7. **Edge Case — Single-Vertical Regression Test** — Insert a fixture where every `(hostname, metric_id)` is unique (no cross-vertical duplicates). Call all five read endpoints and capture responses. Apply the fix, re-run, and assert response equality (byte-for-byte). Run `persistUpload()` on a single-vertical fixture and assert the resulting `compliance_snapshots` rows are identical pre-fix and post-fix. (should pass on unfixed code; will pass on fixed code; protects the preservation property)
|
||||||
|
|
||||||
|
**Expected Counterexamples**:
|
||||||
|
- `/items` returns `failing_metrics` arrays where `length > Set(metric_ids).size`. Cause: `groupByHostname()` pushes per row instead of per unique `metric_id`.
|
||||||
|
- `/items/:hostname` returns `metrics` arrays where `(metric_id, status)` collisions exist. Cause: no vertical filter and no dedup in the detail query or response builder.
|
||||||
|
- `/vcl/stats` returns `heavy_hitters` whose `non_compliant` values sum to more than the global `stats.non_compliant`. Cause: a hostname's `team` differs across verticals and the per-team `COUNT(DISTINCT hostname)` counts it under both teams.
|
||||||
|
- `/vcl/stats` reports `blockers` clamped to 0 when the unclamped expression is negative. Cause: the forecast query is not deduped on `(hostname, metric_id)` so its row count exceeds `teamNonCompliant`.
|
||||||
|
- `/mttr` returns `aging` whose total exceeds the distinct-violation count. Cause: `bucketAgingItems()` iterates rows directly with no dedup.
|
||||||
|
- `persistUpload()` writes `compliance_snapshots` rows where `compliant + non_compliant > total_devices`. Cause: the snapshot query's `CASE WHEN status = 'X'` pattern lets a hostname appear in both columns.
|
||||||
|
|
||||||
|
### Fix Checking
|
||||||
|
|
||||||
|
**Goal**: Verify that for all inputs where the bug condition holds (any `(hostname, metric_id)` shared by two or more rows across verticals), each fixed endpoint produces the expected deduped result.
|
||||||
|
|
||||||
|
**Pseudocode:**
|
||||||
|
```
|
||||||
|
FOR ALL items WHERE EXISTS (h, m) WITH COUNT(items WHERE hostname = h AND metric_id = m) > 1 DO
|
||||||
|
items_response := GET_items_fixed(items)
|
||||||
|
detail_response := GET_items_hostname_fixed(items, some_hostname_with_dups)
|
||||||
|
stats_response := GET_vcl_stats_fixed(items)
|
||||||
|
mttr_response := GET_mttr_fixed(items)
|
||||||
|
snapshot_rows := persistUpload_fixed(no_op_upload, items)
|
||||||
|
|
||||||
|
ASSERT no_duplicate_metrics_per_device(items_response.devices)
|
||||||
|
ASSERT no_duplicate_metric_status_pairs(detail_response.metrics)
|
||||||
|
ASSERT sum_of_team_counts_equals_global(stats_response)
|
||||||
|
ASSERT forecast_blockers_non_negative(stats_response.vertical_breakdown)
|
||||||
|
ASSERT mttr_total_equals_distinct_violations(mttr_response.aging, items)
|
||||||
|
ASSERT snapshot_invariant_holds(snapshot_rows)
|
||||||
|
END FOR
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preservation Checking
|
||||||
|
|
||||||
|
**Goal**: Verify that for all inputs where the bug condition does NOT hold (every `(hostname, metric_id)` is unique across verticals), the fixed endpoints produce results identical to the original endpoints.
|
||||||
|
|
||||||
|
**Pseudocode:**
|
||||||
|
```
|
||||||
|
FOR ALL items WHERE FORALL (h, m), COUNT(items WHERE hostname = h AND metric_id = m) <= 1 DO
|
||||||
|
ASSERT GET_items_original(items) = GET_items_fixed(items)
|
||||||
|
ASSERT GET_items_hostname_original(items) = GET_items_hostname_fixed(items)
|
||||||
|
ASSERT GET_vcl_stats_original(items) = GET_vcl_stats_fixed(items)
|
||||||
|
ASSERT GET_mttr_original(items) = GET_mttr_fixed(items)
|
||||||
|
ASSERT persistUpload_original(items).snapshots = persistUpload_fixed(items).snapshots
|
||||||
|
END FOR
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing Approach**: Property-based testing is the right fit for preservation checking here:
|
||||||
|
- The unique-`(hostname, metric_id)` input space is large (any number of hostnames, any combination of metrics, any team and vertical mix) and exhaustive enumeration is impractical.
|
||||||
|
- The preservation property is strict equality, which is well-suited to PBT shrinking — any counterexample is a small fixture demonstrating a behavior change.
|
||||||
|
- The legacy AEO-only data shape (`vertical IS NULL`) must be exercised, which falls naturally out of generators that include `null` verticals.
|
||||||
|
|
||||||
|
**Test Plan**: Capture responses from the unfixed code on unique-`(hostname, metric_id)` fixtures (snapshot tests). After applying the fix, re-run the same fixtures and assert equality. Then run a property-based generator that produces random unique-key scenarios and asserts the same equality.
|
||||||
|
|
||||||
|
**Test Cases**:
|
||||||
|
1. **Snapshot Equality — Empty State** — Empty `compliance_items`. `/items?team=STEAM` returns `{ devices: [], team: 'STEAM', status: 'active' }`. `/items/:hostname` returns 404. `/vcl/stats` returns its zero-state shape. `/mttr` returns `{ aging: [] }`. `persistUpload()` writes no snapshot rows. Snapshot-test before and after the fix.
|
||||||
|
2. **Snapshot Equality — Single AEO-Only Items** — Items with `vertical IS NULL` only. Capture pre-fix responses, apply fix, assert equality across all five endpoints.
|
||||||
|
3. **Snapshot Equality — Multiple Unique Verticals** — A mix of items with `vertical IS NULL` and `vertical = 'NTS_AEO'`, but no `(hostname, metric_id)` collision across verticals. Capture pre-fix responses, apply fix, assert equality.
|
||||||
|
4. **`/items` Team and Status Filter Preservation** — Active and resolved items mixed across teams. Assert `?team=STEAM&status=active`, `?team=STEAM&status=resolved`, and `?team=ACCESS-ENG&status=active` each return the same devices pre-fix and post-fix on a unique-key fixture. Assert non-`ALLOWED_TEAMS` value (e.g., `?team=OTHER`) returns HTTP 400.
|
||||||
|
5. **`/items/:hostname` Active-Then-Resolved Ordering Preservation** — A device with both active and resolved metrics. Assert the response's `metrics` array has all `active` entries before all `resolved` entries, sorted by `metric_id` within each group, identical to pre-fix.
|
||||||
|
6. **`/vcl/stats` Donut Categorization Preservation** — Active items with mixed null/non-null `resolution_date`. Assert `donut.blocked` and `donut.in_progress` counts match pre-fix on a unique-key fixture (this is already deduped via `GROUP BY hostname` in the existing query).
|
||||||
|
7. **`persistUpload()` Snapshot Equality — Single-Status-Per-Hostname Fixture** — Pre-populate `compliance_items` so every hostname has rows of only one status (all active or all resolved within a team). Run `persistUpload()`. Assert `compliance_snapshots` rows are identical pre-fix and post-fix.
|
||||||
|
|
||||||
|
### Test Fixtures Needed
|
||||||
|
|
||||||
|
The following fixtures must be reusable across unit, integration, and property-based tests. Place them under `backend/__tests__/fixtures/compliance-duplicate-failing-metrics/` (or inline as factory functions in the test file, matching the convention used in `vcl-compliance-reporting.test.js`).
|
||||||
|
|
||||||
|
1. **`fixtureCrossVerticalDuplicateActive`** — A single hostname with two rows for the same `metric_id`, both `status = 'active'`, both `team = 'STEAM'`, one with `vertical IS NULL` and one with `vertical = 'NTS_AEO'`. Different `seen_count` (e.g., 3 and 5) so the representative-row policy is exercised. Used by `/items`, `/items/:hostname`, `/mttr`, and the `/vcl/stats` forecast tests.
|
||||||
|
|
||||||
|
2. **`fixtureCrossVerticalTeamMismatch`** — A single hostname with two active rows for the same `metric_id` but different `team` across verticals (`STEAM` in legacy, `ACCESS-ENG` in NTS_AEO). Used by the `/vcl/stats` heavy-hitters and per-team-totals test (Property 3).
|
||||||
|
|
||||||
|
3. **`fixtureCrossVerticalStatusMismatch`** — A single hostname with two rows for the same `metric_id` and same team, but different `status` across verticals (`active` in legacy, `resolved` in NTS_AEO). Used by the `persistUpload()` snapshot test (Property 6).
|
||||||
|
|
||||||
|
4. **`fixtureMultiHostnameMixed`** — A combination of unique-key hostnames and one duplicated `(hostname, metric_id)`. Used to verify that the dedup applies only to the duplicates and leaves unique entries untouched.
|
||||||
|
|
||||||
|
5. **`fixtureLegacyOnly`** — All rows with `vertical IS NULL`. Used for preservation checks that confirm legacy data flows are unchanged.
|
||||||
|
|
||||||
|
6. **`fixtureNTSAEOOnly`** — All rows with `vertical = 'NTS_AEO'`. Used for preservation checks that confirm multi-vertical-only data flows are unchanged.
|
||||||
|
|
||||||
|
7. **`fixtureForecastDuplicateResolutionDate`** — A duplicated `(hostname, metric_id)` where both rows have a non-null `resolution_date` (same value). Used to verify Property 4 (`blockers >= 0`).
|
||||||
|
|
||||||
|
8. **`fixtureSeenCountTiebreak`** — A duplicated `(hostname, metric_id)` where both rows have the same `seen_count` but different `upload_id`. Used to verify the `upload_id DESC` tiebreaker in the `DISTINCT ON ... ORDER BY` policy.
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
- `/items`: insert `fixtureCrossVerticalDuplicateActive`, assert `failing_metrics.length === 1` and `seen_count === 5`.
|
||||||
|
- `/items/:hostname`: insert `fixtureCrossVerticalDuplicateActive`, assert `metrics.length === 1` for `(7.1.1, active)`.
|
||||||
|
- `/items/:hostname` ordering: insert a fixture with one active and one resolved metric, assert `metrics[0].status === 'active'` and `metrics[1].status === 'resolved'`.
|
||||||
|
- `/vcl/stats` heavy-hitters: insert `fixtureCrossVerticalTeamMismatch`, assert hostname counted exactly once in the team derived from the representative row.
|
||||||
|
- `/vcl/stats` forecast: insert `fixtureForecastDuplicateResolutionDate`, assert `blockers >= 0` and `forecastItems.length === teamNonCompliant`.
|
||||||
|
- `/mttr`: insert `fixtureCrossVerticalDuplicateActive` with `seen_count = 5`, assert exactly one increment in the `4–6 cycles` bucket for `STEAM`.
|
||||||
|
- `persistUpload()` snapshot: insert `fixtureCrossVerticalStatusMismatch`, run `persistUpload()`, assert the resulting snapshot row has `compliant === 0`, `non_compliant === 1`, `total_devices === 1`.
|
||||||
|
- `groupByHostname()` direct: pass a list with two rows for the same `(hostname, metric_id)`, assert one entry in `failing_metrics`. This protects the helper's contract independently of the SQL dedup.
|
||||||
|
|
||||||
|
### Property-Based Tests
|
||||||
|
|
||||||
|
Use `fast-check` (already in use in `backend/__tests__/*.property.test.js`). Generators should mix `vertical IS NULL` and `vertical = 'NTS_AEO'` rows, with controllable rates of `(hostname, metric_id)` collision.
|
||||||
|
|
||||||
|
- **Property 1 — `/items`**: For any list of `compliance_items` rows including cross-vertical duplicates, assert that for every device in the response, `device.failing_metrics.length === new Set(device.failing_metrics.map(m => m.metric_id)).size`.
|
||||||
|
- **Property 2 — `/items/:hostname`**: For any duplicated `(hostname, metric_id, status)`, assert `response.metrics.filter(m => m.metric_id === target_metric && m.status === target_status).length === 1`. Assert the surviving entry's `seen_count` is the maximum across duplicates.
|
||||||
|
- **Property 3 — `/vcl/stats`**: For any input including team-mismatched cross-vertical duplicates, assert `SUM(heavy_hitters[*].non_compliant) === stats.non_compliant`. Generate inputs that vary the rate of team mismatch from 0% to 100%.
|
||||||
|
- **Property 4 — `/vcl/stats` forecast**: For any input including duplicated `(hostname, metric_id)` with non-null resolution dates, assert `vertical_breakdown[*].blockers >= 0` and assert the unclamped expression equals `teamNonCompliant - dedupedForecastCount`.
|
||||||
|
- **Property 5 — `/mttr`**: For any input including cross-vertical duplicate active rows, assert `SUM(aging[*].total) === COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')` over the input. Per-team totals: assert `SUM(aging[*][team])` over each team equals the distinct count for that team.
|
||||||
|
- **Property 6 — `persistUpload()` snapshot invariant**: For any combination of cross-vertical status mismatches, assert every row written into `compliance_snapshots` satisfies `compliant + non_compliant <= total_devices` and `compliant + non_compliant === total_devices` (equality, since every hostname is classified into exactly one column).
|
||||||
|
- **Property 7 — Preservation**: For any input where every `(hostname, metric_id)` is unique across verticals, the responses from all five endpoints SHALL equal the responses on the same input from the original (unfixed) implementations. Use fast-check's shrinking to surface the smallest counterexample if the property fails.
|
||||||
|
- **Property 8 — Representative-row policy**: For any duplicated `(hostname, metric_id)`, the surviving entry's `seen_count` SHALL equal `max(duplicates.seen_count)`, and ties SHALL be broken by `max(duplicates.upload_id)`.
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- Full flow: upload a legacy AEO xlsx, then upload an NTS_AEO multi-vertical xlsx that overlaps on at least one `(hostname, metric_id)`. Call `/items`, `/items/:hostname`, `/vcl/stats`, `/mttr`, and read `compliance_snapshots` for the current month. Assert all six post-fix invariants hold.
|
||||||
|
- Cross-page consistency: load `/vcl/stats` and `/items?team=STEAM` for the same backing data. Assert that the device count from `/vcl/stats` heavy-hitters for `STEAM` equals the number of devices in `/items?team=STEAM&status=active`.
|
||||||
|
- ComplianceDetailPanel render: load a device with cross-vertical duplicates via `/items/:hostname`, render `ComplianceDetailPanel`, assert exactly one `MetricChip` with the duplicated `metric_id` is present in the "Failing Metrics" section.
|
||||||
|
- `persistUpload()` end-to-end: with cross-vertical status mismatch present, run a fresh upload and assert the new `compliance_snapshots` row's invariant holds.
|
||||||
204
.kiro/specs/compliance-duplicate-failing-metrics/tasks.md
Normal file
204
.kiro/specs/compliance-duplicate-failing-metrics/tasks.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This implementation plan delivers six SQL-level fixes in `backend/routes/compliance.js` for the cross-vertical duplicate `(hostname, metric_id)` bug class documented in `bugfix.md` and `design.md`. Each fix targets a single endpoint or persistence block:
|
||||||
|
|
||||||
|
1. `GET /items` — `DISTINCT ON (hostname, metric_id)` plus a defensive `groupByHostname` Set
|
||||||
|
2. `GET /items/:hostname` — `DISTINCT ON (metric_id, status)` with JS-side sort
|
||||||
|
3. `GET /vcl/stats` heavy-hitters and per-team totals — `device_team` CTE
|
||||||
|
4. `GET /vcl/stats` forecast-burndown — `DISTINCT ON (hostname, metric_id)`
|
||||||
|
5. `GET /mttr` — `DISTINCT ON (hostname, metric_id)`
|
||||||
|
6. `persistUpload()` snapshot block — `hostname_status` CTE classifying each hostname via `MIN(status)`
|
||||||
|
|
||||||
|
The plan follows the bugfix workflow: a single property-based exploration test (Property 1) covering all six affected sites runs on UNFIXED code first to confirm the bug exists, then preservation property tests (Property 2) capture single-vertical and unique-key behaviour as a baseline. Implementation tasks apply each fix in order (3.1–3.6), followed by re-running the exploration test (3.7) and the preservation suite (3.8). The checkpoint runs the full backend test suite to confirm no regressions.
|
||||||
|
|
||||||
|
## Task Dependency Graph
|
||||||
|
|
||||||
|
Tasks are organised into execution waves. Tasks within the same wave may run in parallel; tasks in later waves depend on the completion of earlier waves. Sub-tasks 3.1–3.6 are independent of each other (each touches a distinct query in `backend/routes/compliance.js`) and form a single parallel wave; sub-tasks 3.7 and 3.8 depend on all six fixes being landed.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"waves": [
|
||||||
|
{
|
||||||
|
"wave": 1,
|
||||||
|
"description": "Write the bug-condition exploration property test on UNFIXED code (Property 1, six slices covering /items, /items/:hostname, /vcl/stats heavy-hitters, /vcl/stats forecast-burndown, /mttr, persistUpload snapshot).",
|
||||||
|
"tasks": ["1"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wave": 2,
|
||||||
|
"description": "Write the preservation property tests on UNFIXED code (observation-first methodology), recording baseline responses for single-vertical and unique-key fixtures across all five read endpoints and persistUpload(). Property 8.A is written but skipped pending the fix.",
|
||||||
|
"tasks": ["2"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wave": 3,
|
||||||
|
"description": "Apply the six SQL-level fixes in backend/routes/compliance.js. Each sub-task targets a distinct query and is independent of the others — sub-tasks 3.1 through 3.6 can be implemented in parallel.",
|
||||||
|
"tasks": ["3.1", "3.2", "3.3", "3.4", "3.5", "3.6"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wave": 4,
|
||||||
|
"description": "Re-run the exploration test (3.7) and preservation tests (3.8) against the fixed code. 3.7 confirms all six bug-condition slices now pass; 3.8 confirms preservation properties still pass and unskips Property 8.A to verify the representative-row policy.",
|
||||||
|
"tasks": ["3.7", "3.8"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wave": 5,
|
||||||
|
"description": "Checkpoint — run the full backend Jest suite to confirm no regressions in adjacent compliance tests (vcl-compliance-reporting, vcl-aggregated-burndown).",
|
||||||
|
"tasks": ["4"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Write bug condition exploration property test
|
||||||
|
- **Property 1: Bug Condition** - Cross-Vertical Duplicate `(hostname, metric_id)` Distorts Compliance Endpoints
|
||||||
|
- **CRITICAL**: This test MUST FAIL on unfixed code — failure confirms the bug exists in all six affected sites
|
||||||
|
- **DO NOT attempt to fix the test or the code when it fails**
|
||||||
|
- **NOTE**: This test encodes the expected behaviour (Property 1–6 from design.md). It will validate the fix when it passes after implementation.
|
||||||
|
- **GOAL**: Surface counterexamples that demonstrate cross-vertical duplicate `(hostname, metric_id)` rows distort `/items`, `/items/:hostname`, `/vcl/stats` heavy-hitters, `/vcl/stats` forecast-burndown, `/mttr`, and the `persistUpload()` snapshot block
|
||||||
|
- **Scoped PBT Approach**: Six concrete failing scenarios (one per affected site) generated by fast-check from a small fixed input space — each scenario seeds two `compliance_items` rows for the same `(hostname, metric_id)` across `vertical IS NULL` and `vertical = 'NTS_AEO'`. Use `fast-check` (already in use under `backend/__tests__/*.property.test.js`).
|
||||||
|
- Place the test at `backend/__tests__/compliance-duplicate-failing-metrics.exploration.property.test.js`. Mock `../db` with `jest.mock` exactly like `vcl-compliance-reporting.property.test.js` so route handlers can be invoked against an in-memory fixture, or stand up a transactional `pg` test schema if the repo already supports one — match whichever convention the existing compliance tests use.
|
||||||
|
- **Slice 1.A — `/items` failing-metrics dedup (Bug Condition isBugCondition: two active rows for `(STEAM-INTERSIGHT, 7.1.1)`, one `vertical IS NULL`, one `vertical = 'NTS_AEO'`, both `team = 'STEAM'`)**
|
||||||
|
- Seed `fixtureCrossVerticalDuplicateActive` from design.md §Test Fixtures (different `seen_count`: 3 and 5)
|
||||||
|
- Call `GET /items?team=STEAM&status=active`
|
||||||
|
- Assert `response.devices[0].failing_metrics.filter(m => m.metric_id === '7.1.1').length === 1` (Property 1 from design.md)
|
||||||
|
- **EXPECTED OUTCOME on unfixed code**: FAILS — `failing_metrics` contains two `7.1.1` entries because `groupByHostname` pushes per row
|
||||||
|
- **Slice 1.B — `/items/:hostname` `(metric_id, status)` dedup (same fixture)**
|
||||||
|
- Call `GET /items/STEAM-INTERSIGHT`
|
||||||
|
- Assert `response.metrics.filter(m => m.metric_id === '7.1.1' && m.status === 'active').length === 1` (Property 2 from design.md)
|
||||||
|
- Assert the surviving entry's `seen_count === 5` (max across duplicates per Property 8)
|
||||||
|
- **EXPECTED OUTCOME on unfixed code**: FAILS — detail query has no vertical filter and no dedup, returns two `7.1.1/active` entries
|
||||||
|
- **Slice 1.C — `/vcl/stats` heavy-hitters cross-team (Bug Condition: two active rows for the same `(hostname, metric_id)` whose `team` differs across verticals)**
|
||||||
|
- Seed `fixtureCrossVerticalTeamMismatch` from design.md §Test Fixtures (`team = 'STEAM'` legacy, `team = 'ACCESS-ENG'` NTS_AEO)
|
||||||
|
- Call `GET /vcl/stats`
|
||||||
|
- Assert `SUM(heavy_hitters[*].non_compliant) === stats.non_compliant` (Property 3 from design.md)
|
||||||
|
- **EXPECTED OUTCOME on unfixed code**: FAILS — sum exceeds the global count by 1 because `COUNT(DISTINCT hostname) GROUP BY team` counts the hostname under both teams
|
||||||
|
- **Slice 1.D — `/vcl/stats` forecast-burndown blockers (Bug Condition: two active rows for the same `(hostname, metric_id)` both with non-null `resolution_date`, same team)**
|
||||||
|
- Seed `fixtureForecastDuplicateResolutionDate` from design.md §Test Fixtures (`team = 'STEAM'`, both `resolution_date = '2025-09-30'`)
|
||||||
|
- Call `GET /vcl/stats`, locate the `STEAM` entry in `vertical_breakdown`
|
||||||
|
- Assert the unclamped `teamNonCompliant - forecastItems.length === blockers` AND `blockers >= 0` (Property 4 from design.md)
|
||||||
|
- **EXPECTED OUTCOME on unfixed code**: FAILS — unclamped value is `-1`, route reports `0` after `Math.max(blockers, 0)`, hiding the inconsistency. The unclamped check makes the failure visible
|
||||||
|
- **Slice 1.E — `/mttr` aging buckets (same fixture as Slice 1.A, with `seen_count = 5` on both rows)**
|
||||||
|
- Call `GET /mttr`
|
||||||
|
- Assert `SUM(aging[*].total) === COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')` over the seeded items (Property 5 from design.md)
|
||||||
|
- **EXPECTED OUTCOME on unfixed code**: FAILS — `bucketAgingItems()` increments the `4–6 cycles` bucket twice, exceeding the distinct count by 1
|
||||||
|
- **Slice 1.F — `persistUpload()` snapshot block (Bug Condition: same `(hostname, metric_id, team)` with `status = 'active'` in legacy and `status = 'resolved'` in NTS_AEO)**
|
||||||
|
- Seed `fixtureCrossVerticalStatusMismatch` from design.md §Test Fixtures
|
||||||
|
- Run `persistUpload()` with a no-op upload, read back the `compliance_snapshots` row for the current month and `STEAM`
|
||||||
|
- Assert `compliant + non_compliant <= total_devices` (Property 6 from design.md)
|
||||||
|
- **EXPECTED OUTCOME on unfixed code**: FAILS — the hostname is counted in both `compliant` and `non_compliant`, sum exceeds `total_devices` by 1
|
||||||
|
- Run on UNFIXED code and capture all six counterexamples in the test output
|
||||||
|
- Document the six counterexamples in the test file (a leading comment block listing the failing slice → symptom mapping) so the bug surface is recoverable from the test alone
|
||||||
|
- Mark task complete when the test is written, run, and all six slice failures are documented
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 2. Write preservation property tests (BEFORE implementing fix)
|
||||||
|
- **Property 2: Preservation** - Single-Vertical and Unique-Key Inputs Are Byte-For-Byte Unchanged
|
||||||
|
- **IMPORTANT**: Follow observation-first methodology — observe behaviour on UNFIXED code, then write property tests that capture it
|
||||||
|
- Place the test at `backend/__tests__/compliance-duplicate-failing-metrics.preservation.property.test.js`. Use `fast-check` and the same `jest.mock('../db', ...)` pattern as the existing property tests
|
||||||
|
- Build the unique-key generator: `fc.array(...)` of `compliance_items` rows where every `(hostname, metric_id)` pair is unique across the array. Mix `vertical IS NULL`, `vertical = 'NTS_AEO'`, and other verticals; mix `status = 'active'` and `status = 'resolved'`; mix teams from `ALLOWED_TEAMS`
|
||||||
|
- **Observation step**: For each fixture from design.md §Test Fixtures (`fixtureLegacyOnly`, `fixtureNTSAEOOnly`, `fixtureMultiHostnameMixed` restricted to its unique-key subset, plus a hand-built empty-state fixture), run all five read endpoints + `persistUpload()` on UNFIXED code and capture responses. Persist these as snapshot fixtures alongside the test (e.g., a small JSON file under `backend/__tests__/fixtures/compliance-duplicate-failing-metrics/`) so the test compares against the recorded baseline rather than against a moving target
|
||||||
|
- **Property 7.A — `/items` unique-key preservation**: For any unique-key fixture, assert the response from `GET /items?team=...&status=...` (across `team ∈ ALLOWED_TEAMS` and `status ∈ {'active', 'resolved'}`) equals the recorded baseline byte-for-byte (deep equality on the JSON response)
|
||||||
|
- **Property 7.B — `/items/:hostname` unique-key preservation**: For any unique-key fixture, assert the response from `GET /items/:hostname` for every seeded hostname equals the recorded baseline. Also assert active-then-resolved ordering is preserved (`response.metrics[0..k].status === 'active'` then `response.metrics[k+1..].status === 'resolved'`, sorted by `metric_id` within each group), per design.md §Preservation Requirements item 5
|
||||||
|
- **Property 7.C — `/vcl/stats` unique-key preservation**: Assert response equality across `stats.compliant`, `stats.non_compliant`, the `donut` block (`blocked` / `in_progress`), `heavy_hitters` (full array), and `vertical_breakdown` (full array including `blockers`). Generate inputs that vary `resolution_date` density to exercise the donut categorisation
|
||||||
|
- **Property 7.D — `/mttr` unique-key preservation**: Assert response equality on the full `aging` array (per-bucket per-team totals)
|
||||||
|
- **Property 7.E — `persistUpload()` unique-key preservation**: For a single-status-per-hostname fixture (every hostname has only `active` or only `resolved` rows, never both, within a team), run `persistUpload()` and assert the `compliance_snapshots` rows for the current `(snapshot_month, vertical)` are identical to the recorded baseline
|
||||||
|
- **Property 7.F — `/items` query-param validation preservation**: Assert that `?team=OTHER` (not in `ALLOWED_TEAMS`) returns HTTP 400 and `?status=invalid` returns HTTP 400, on both unfixed and fixed code. Assert `/items/:hostname` for an unknown hostname returns HTTP 404
|
||||||
|
- **Property 8.A — Representative-row policy on duplicates**: For inputs WITH duplicates, assert the surviving entry carries `seen_count = MAX(seen_count)`, `first_seen = MIN(first_seen)`, `last_seen = MAX(last_seen)` across the duplicate rows (this is the only preservation property that exercises the duplicate path, since it specifies WHAT the dedup must produce — keep it in the preservation file because it defines the contract the fix must satisfy)
|
||||||
|
- Run the full preservation suite on UNFIXED code
|
||||||
|
- **EXPECTED OUTCOME**: Properties 7.A–7.F PASS on unfixed code (they describe baseline behaviour to preserve). Property 8.A is the only one expected to FAIL on unfixed code, since it asserts the post-fix representative-row contract — exclude it from the unfixed-code run or mark it as `test.skip` until the fix lands, with a comment pointing to task 3.5
|
||||||
|
- Mark task complete when 7.A–7.F are written, run, and passing on unfixed code, and 8.A is written but skipped pending the fix
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7_
|
||||||
|
|
||||||
|
- [x] 3. Fix for cross-vertical duplicate `(hostname, metric_id)` rows distorting compliance endpoints
|
||||||
|
|
||||||
|
- [x] 3.1 Implement Fix 1: `GET /items` `DISTINCT ON (hostname, metric_id)` and defensive `groupByHostname` dedup
|
||||||
|
- Edit `backend/routes/compliance.js`, `router.get('/items', ...)` (around line 535)
|
||||||
|
- Rewrite the items query as `SELECT DISTINCT ON (ci.hostname, ci.metric_id) ... ORDER BY ci.hostname, ci.metric_id, ci.seen_count DESC, ci.upload_id DESC` keeping the existing `WHERE ci.team = $1 AND ci.status = $2 AND (ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')` predicate intact (per design.md Fix 1 step 1 and Preservation Requirements bullet 4)
|
||||||
|
- Edit `groupByHostname()` (around line 213) to add a `_seenMetricIds` Set per device, push `failing_metrics` only when the `metric_id` has not been seen, aggregate `seen_count` via `Math.max`, `first_seen` via `Math.min`, `last_seen` via `Math.max`, and strip `_seenMetricIds` from the returned device object so the response shape is unchanged (per design.md Fix 1 step 2)
|
||||||
|
- _Bug_Condition: isBugCondition(items) — two or more rows share `(hostname, metric_id)` across verticals (design.md §Bug Condition)_
|
||||||
|
- _Expected_Behavior: Property 1 — `device.failing_metrics.length === new Set(device.failing_metrics.map(m => m.metric_id)).size` (design.md §Correctness Properties Property 1)_
|
||||||
|
- _Preservation: Single-vertical and unique-key inputs unchanged; `(ci.vertical IS NULL OR ci.vertical = 'NTS_AEO')` predicate retained; `team`/`status` query-param validation unchanged (design.md §Preservation Requirements bullets 1, 4, 7)_
|
||||||
|
- _Requirements: 2.1, 3.1, 3.3, 3.4_
|
||||||
|
|
||||||
|
- [x] 3.2 Implement Fix 2: `GET /items/:hostname` `DISTINCT ON (metric_id, status)` and JS sort
|
||||||
|
- Edit `backend/routes/compliance.js`, `router.get('/items/:hostname', ...)` (around line 575)
|
||||||
|
- Rewrite the metrics query as `SELECT DISTINCT ON (ci.metric_id, ci.status) ... FROM compliance_items ci ... WHERE ci.hostname = $1 ORDER BY ci.metric_id, ci.status, ci.seen_count DESC, ci.upload_id DESC` (per design.md Fix 2 step 1)
|
||||||
|
- Add a JS-side sort on the deduped rows that reproduces the original `ORDER BY ci.status DESC, ci.metric_id` ordering — `metricRows.sort((a, b) => a.status !== b.status ? b.status.localeCompare(a.status) : a.metric_id.localeCompare(b.metric_id))` (per design.md Fix 2 step 2)
|
||||||
|
- Leave the existing `metricRows.find(r => r.status === 'active') || metricRows[0]` identity lookup unchanged (per design.md Fix 2 step 3)
|
||||||
|
- _Bug_Condition: isBugCondition(items) plus duplicate rows for the same `hostname` across verticals with no vertical filter on the detail query_
|
||||||
|
- _Expected_Behavior: Property 2 — exactly one entry per `(metric_id, status)` pair, surviving entry carries `MAX(seen_count)` across duplicates_
|
||||||
|
- _Preservation: active-before-resolved ordering, `metric_id` ordering within each status group, identity lookup unchanged_
|
||||||
|
- _Requirements: 2.2, 2.3, 3.2, 3.3_
|
||||||
|
|
||||||
|
- [x] 3.3 Implement Fix 3: `/vcl/stats` heavy-hitters and per-team totals via `device_team` CTE
|
||||||
|
- Edit `backend/routes/compliance.js`, `router.get('/vcl/stats', ...)` (around line 990, the `teamRows` query)
|
||||||
|
- Replace the heavy-hitters query with a CTE: `WITH device_team AS (SELECT DISTINCT ON (hostname) hostname, COALESCE(team, 'Unknown') AS team, resolution_date FROM compliance_items WHERE status = 'active' ORDER BY hostname, seen_count DESC, upload_id DESC) SELECT team, COUNT(DISTINCT hostname)::int AS non_compliant, MAX(resolution_date) AS compliance_date FROM device_team GROUP BY team ORDER BY COUNT(DISTINCT hostname) DESC` (per design.md Fix 3 step 1)
|
||||||
|
- Rewrite the per-team-total query inside the `for (const teamRow of teamRows)` loop to use the same `device_team`-style CTE: `WITH device_team AS (SELECT DISTINCT ON (hostname) hostname, COALESCE(team, 'Unknown') AS team FROM compliance_items ORDER BY hostname, seen_count DESC, upload_id DESC) SELECT COUNT(*)::int AS total FROM device_team WHERE team = $1` (per design.md Fix 3 step 3)
|
||||||
|
- Leave the global `stats.non_compliant` query unchanged — it is already correct (per design.md Fix 3 step 2)
|
||||||
|
- _Bug_Condition: a hostname's `team` differs across verticals so `COUNT(DISTINCT hostname) GROUP BY team` counts it under both teams_
|
||||||
|
- _Expected_Behavior: Property 3 — `SUM(heavy_hitters[*].non_compliant) === stats.non_compliant` and each hostname assigned to exactly one team (the team from its representative row)_
|
||||||
|
- _Preservation: global `stats.compliant` / `stats.non_compliant` unchanged, donut categorisation unchanged_
|
||||||
|
- _Requirements: 2.5, 3.6_
|
||||||
|
|
||||||
|
- [x] 3.4 Implement Fix 4: `/vcl/stats` forecast-burndown `DISTINCT ON (hostname, metric_id)`
|
||||||
|
- Edit `backend/routes/compliance.js`, `router.get('/vcl/stats', ...)` (around line 1015, the `forecastItems` query)
|
||||||
|
- Rewrite as `SELECT DISTINCT ON (hostname, metric_id) resolution_date FROM compliance_items WHERE status = 'active' AND COALESCE(team, 'Unknown') = $1 AND resolution_date IS NOT NULL ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC` (per design.md Fix 4 step 1)
|
||||||
|
- Leave `blockers = teamNonCompliant - forecastItems.length` and the `Math.max(blockers, 0)` clamp unchanged — the clamp becomes a no-op in correct operation but stays as belt-and-braces (per design.md Fix 4 steps 2 and 3)
|
||||||
|
- _Bug_Condition: duplicate `(hostname, metric_id)` rows with non-null `resolution_date` inflate `forecastItems.length` past `teamNonCompliant`_
|
||||||
|
- _Expected_Behavior: Property 4 — unclamped `teamNonCompliant - dedupedForecastCount === blockers` and `blockers >= 0`_
|
||||||
|
- _Preservation: `Math.max(blockers, 0)` clamp retained as a no-op safeguard, downstream forecast bucketing unchanged_
|
||||||
|
- _Requirements: 2.6, 3.6_
|
||||||
|
|
||||||
|
- [x] 3.5 Implement Fix 5: `/mttr` `DISTINCT ON (hostname, metric_id)` in SQL
|
||||||
|
- Edit `backend/routes/compliance.js`, `router.get('/mttr', ...)` (around line 824)
|
||||||
|
- Rewrite the query as `SELECT DISTINCT ON (hostname, metric_id) COALESCE(seen_count, 1) AS seen_count, team FROM compliance_items WHERE status = 'active' ORDER BY hostname, metric_id, seen_count DESC, upload_id DESC` (per design.md Fix 5 step 1)
|
||||||
|
- Leave `bucketAgingItems()` unchanged — its contract is preserved because it is also called from `/vcl/stats` (per design.md Fix 5 steps 2 and 3)
|
||||||
|
- _Bug_Condition: duplicate active rows for the same `(hostname, metric_id)` are bucketed twice by `bucketAgingItems()`_
|
||||||
|
- _Expected_Behavior: Property 5 — `SUM(aging[*].total) === COUNT(DISTINCT (hostname, metric_id) WHERE status = 'active')` and each unique violation bucketed exactly once with a single representative `seen_count`_
|
||||||
|
- _Preservation: `bucketAgingItems()` contract unchanged, per-team buckets on single-vertical fixtures unchanged_
|
||||||
|
- _Requirements: 2.7, 3.7_
|
||||||
|
|
||||||
|
- [x] 3.6 Implement Fix 6: `persistUpload()` snapshot via `hostname_status` CTE
|
||||||
|
- Edit `backend/routes/compliance.js`, `persistUpload()` (lines 81–192), specifically the `verticalStats` query at line 157
|
||||||
|
- Rewrite the snapshot query as `WITH hostname_status AS (SELECT team, hostname, MIN(status) AS status FROM compliance_items WHERE team IS NOT NULL GROUP BY team, hostname) SELECT team AS vertical, COUNT(*)::int AS total_devices, COUNT(*) FILTER (WHERE status = 'resolved')::int AS compliant, COUNT(*) FILTER (WHERE status = 'active')::int AS non_compliant FROM hostname_status GROUP BY team` (per design.md Fix 6 step 1)
|
||||||
|
- Leave the downstream `INSERT ... ON CONFLICT (snapshot_month, vertical) DO UPDATE` block and `compliance_pct` calculation unchanged (per design.md Fix 6 steps 2 and 3)
|
||||||
|
- _Bug_Condition: a hostname has both `active` and `resolved` rows for the same team across verticals, so the `CASE WHEN status = 'X' THEN hostname END` pattern lets it appear in both `compliant` and `non_compliant`_
|
||||||
|
- _Expected_Behavior: Property 6 — every snapshot row satisfies `compliant + non_compliant === total_devices` (active wins over resolved via `MIN(status)`)_
|
||||||
|
- _Preservation: snapshot rows for single-status-per-hostname fixtures unchanged, error-handling try/catch unchanged_
|
||||||
|
- _Requirements: 2.4, 3.5_
|
||||||
|
|
||||||
|
- [x] 3.7 Verify bug condition exploration test now passes
|
||||||
|
- **Property 1: Expected Behavior** - Cross-Vertical Duplicate `(hostname, metric_id)` Distorts Compliance Endpoints
|
||||||
|
- **IMPORTANT**: Re-run the SAME test from task 1 — do NOT write a new test
|
||||||
|
- The test from task 1 encodes the expected behaviour for all six slices (1.A–1.F). When all six slices pass, the bug is fixed across every affected site
|
||||||
|
- Run `npx jest backend/__tests__/compliance-duplicate-failing-metrics.exploration.property.test.js --runInBand` (or the repo's equivalent jest invocation)
|
||||||
|
- **EXPECTED OUTCOME**: All six slices PASS — Slice 1.A (`/items` dedup), Slice 1.B (`/items/:hostname` dedup), Slice 1.C (`/vcl/stats` heavy-hitters), Slice 1.D (`/vcl/stats` forecast blockers), Slice 1.E (`/mttr` aging), Slice 1.F (`persistUpload()` snapshot invariant)
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 3.8 Verify preservation tests still pass and unskip Property 8.A
|
||||||
|
- **Property 2: Preservation** - Single-Vertical and Unique-Key Inputs Are Byte-For-Byte Unchanged
|
||||||
|
- **IMPORTANT**: Re-run the SAME tests from task 2 — do NOT write new tests
|
||||||
|
- Unskip Property 8.A (the representative-row policy assertion) which was deferred in task 2 because it asserts the post-fix contract
|
||||||
|
- Run `npx jest backend/__tests__/compliance-duplicate-failing-metrics.preservation.property.test.js --runInBand`
|
||||||
|
- **EXPECTED OUTCOME**: Properties 7.A–7.F continue to PASS (no regressions on unique-key or single-vertical inputs) and Property 8.A now PASSES on the duplicate path (`seen_count = MAX`, `first_seen = MIN`, `last_seen = MAX` across duplicates)
|
||||||
|
- Confirm the recorded baseline JSON snapshots match the post-fix output for every unique-key fixture
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7_
|
||||||
|
|
||||||
|
- [x] 4. Checkpoint - Run full backend test suite and confirm all tests pass
|
||||||
|
- Run the full backend test suite from `backend/`: `npx jest --runInBand` (or the repo's standard test command)
|
||||||
|
- Confirm both new property test files pass and that no existing tests under `backend/__tests__/` regressed — particularly `vcl-compliance-reporting.property.test.js`, `vcl-aggregated-burndown.property.test.js`, and `vcl-compliance-reporting.test.js`, all of which exercise overlapping compliance code paths
|
||||||
|
- If any pre-existing test fails, diagnose whether the failure is a genuine regression introduced by the fix or a pre-existing flake. If a regression, return to the relevant sub-task in step 3 and adjust the fix; do not silence the failing test
|
||||||
|
- Ask the user if any unexpected questions arise about test scope, fixture naming, or whether any preservation snapshot needs to be re-recorded against the fixed code
|
||||||
|
- Mark complete when the full suite is green
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Fix sequencing**: Tasks 3.1 through 3.6 are independent — each one targets a distinct query and can be implemented and committed in any order. Implementers may parallelise work across these sub-tasks, but tasks 3.7 and 3.8 depend on all six fixes being landed before they can validate.
|
||||||
|
- **Test framework**: Both new property tests follow the existing `backend/__tests__/*.property.test.js` convention — `fast-check` for generators, `jest.mock('../db', ...)` for mocking the `pg` pool, and matching helper imports (`auditLog`, `ivantiApi`) where required. See `vcl-compliance-reporting.property.test.js` for the canonical pattern.
|
||||||
|
- **Fixture location**: Place fixtures at `backend/__tests__/fixtures/compliance-duplicate-failing-metrics/` if a directory-based layout is preferred, or inline as factory functions in the test files. Match whichever convention the existing compliance test files use — if they inline factories (as `vcl-compliance-reporting.test.js` does), follow suit.
|
||||||
|
- **Property 8.A skip**: Property 8.A in task 2 is intentionally skipped on unfixed code because it asserts the post-fix representative-row contract. Task 3.8 unskips it. This is the only test that exercises the duplicate path inside the preservation file; it lives there because the contract it captures is precisely the one preservation must not violate after the fix lands.
|
||||||
|
- **Adjacent spec coordination**: Fix 6 (`persistUpload()` snapshot) is conceptually adjacent to but mechanically distinct from the `persistUpload` fix in `compliance-duplicate-chart-entries`. That spec adds a `WHERE vertical = $1` filter to scope the snapshot to one vertical; this spec adds the `hostname_status` CTE so each hostname is classified once within whichever vertical is snapshotted. Both are independently necessary. If both specs land in the same release, ensure the merged query carries both the vertical filter AND the `hostname_status` CTE.
|
||||||
|
- **`Math.max(blockers, 0)` clamp**: Left in place as a belt-and-braces safeguard per design.md Fix 4 step 3. After the fix it becomes a no-op; if a future regression reintroduces inconsistency, Property 4 (Slice 1.D) catches it before the clamp can mask the underlying bug.
|
||||||
|
- **Documentation follow-up**: Per `.kiro/steering/workflow.md`, after the fix lands and is committed to `master`, add a bug report under `docs/bug-reports/` on the `ops/records` branch using the `compliance-duplicate-failing-metrics-<YYYY-MM-DD>.md` naming convention. Each of the six fix sites is a separate `## Bug N` entry following the Symptom → Cause → Fix triad.
|
||||||
1
.kiro/specs/remediation-plan-history/.config.kiro
Normal file
1
.kiro/specs/remediation-plan-history/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "731f6cf9-7d25-41ea-a550-745d6a917b3f", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
279
.kiro/specs/remediation-plan-history/design.md
Normal file
279
.kiro/specs/remediation-plan-history/design.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# Design Document: Remediation Plan History
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Adds an append-only audit trail for resolution_date and remediation_plan changes on compliance items. The design preserves the existing compliance_items schema (current values remain directly queryable) and introduces a new `compliance_item_history` table for historical entries. The pattern mirrors how `compliance_notes` works — separate rows with timestamps and attribution.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ComplianceDetailPanel │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
|
||||||
|
│ │ Resolution │ │ Remediation │ │ Change Reason (text) │ │
|
||||||
|
│ │ Date Input │ │ Plan Input │ │ │ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────────────┴──────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ PATCH /metadata │
|
||||||
|
│ { resolution_date, remediation_plan, │
|
||||||
|
│ change_reason } │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Change History Section │ │
|
||||||
|
│ │ [date] [field] [old→new] [by user] [reason] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Backend (compliance.js) │
|
||||||
|
│ │
|
||||||
|
│ PATCH /items/:hostname/metadata │
|
||||||
|
│ 1. Validate inputs │
|
||||||
|
│ 2. SELECT current values from compliance_items │
|
||||||
|
│ 3. Compare old vs new — skip if identical │
|
||||||
|
│ 4. INSERT into compliance_item_history (per changed field) │
|
||||||
|
│ 5. UPDATE compliance_items with new values │
|
||||||
|
│ │
|
||||||
|
│ GET /items/:hostname │
|
||||||
|
│ (existing) + SELECT from compliance_item_history │
|
||||||
|
│ LIMIT 10 ORDER BY changed_at DESC │
|
||||||
|
│ │
|
||||||
|
│ POST /vcl/bulk-commit │
|
||||||
|
│ For each hostname: same compare-then-insert pattern │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ │
|
||||||
|
│ compliance_items (unchanged) │
|
||||||
|
│ resolution_date DATE │
|
||||||
|
│ remediation_plan TEXT │
|
||||||
|
│ │
|
||||||
|
│ compliance_item_history (new) │
|
||||||
|
│ id, hostname, field_name, old_value, new_value, │
|
||||||
|
│ change_reason, changed_by, changed_at │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### New Table: compliance_item_history
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_item_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
change_reason TEXT,
|
||||||
|
changed_by TEXT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
||||||
|
ON compliance_item_history(hostname, field_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
||||||
|
ON compliance_item_history(changed_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing Table: compliance_items (no changes)
|
||||||
|
|
||||||
|
The `resolution_date` and `remediation_plan` columns remain as-is. They continue to hold the current/latest value for direct querying by VCL reports.
|
||||||
|
|
||||||
|
## API Changes
|
||||||
|
|
||||||
|
### PATCH /api/compliance/items/:hostname/metadata
|
||||||
|
|
||||||
|
**Request body changes:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resolution_date": "2026-03-15",
|
||||||
|
"remediation_plan": "Upgrade firmware to v4.2",
|
||||||
|
"change_reason": "Vendor pushed back delivery date"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New optional field: `change_reason` (string, max 500 characters, nullable).
|
||||||
|
|
||||||
|
**Behavior changes:**
|
||||||
|
|
||||||
|
1. Before updating compliance_items, query the current values:
|
||||||
|
```sql
|
||||||
|
SELECT DISTINCT ON (hostname) resolution_date, remediation_plan
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE hostname = $1 AND status = 'active'
|
||||||
|
ORDER BY hostname, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
```
|
||||||
|
|
||||||
|
2. For each field being updated, compare old vs new. If different, insert a history row:
|
||||||
|
```sql
|
||||||
|
INSERT INTO compliance_item_history (hostname, field_name, old_value, new_value, change_reason, changed_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Then proceed with the existing UPDATE as before.
|
||||||
|
|
||||||
|
**Response:** unchanged (`{ updated: number }`).
|
||||||
|
|
||||||
|
### GET /api/compliance/items/:hostname
|
||||||
|
|
||||||
|
**Response changes:**
|
||||||
|
|
||||||
|
Add a `history` array to the response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hostname": "server01.example.com",
|
||||||
|
"ip_address": "10.0.1.5",
|
||||||
|
"device_type": "Server",
|
||||||
|
"team": "STEAM",
|
||||||
|
"resolution_date": "2026-03-15",
|
||||||
|
"remediation_plan": "Upgrade firmware to v4.2",
|
||||||
|
"metrics": [...],
|
||||||
|
"notes": [...],
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"field_name": "resolution_date",
|
||||||
|
"old_value": "2026-02-01",
|
||||||
|
"new_value": "2026-03-15",
|
||||||
|
"change_reason": "Vendor pushed back delivery date",
|
||||||
|
"changed_by": "jsmith",
|
||||||
|
"changed_at": "2026-01-20T14:30:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 41,
|
||||||
|
"field_name": "remediation_plan",
|
||||||
|
"old_value": null,
|
||||||
|
"new_value": "Upgrade firmware to v4.2",
|
||||||
|
"change_reason": null,
|
||||||
|
"changed_by": "jsmith",
|
||||||
|
"changed_at": "2026-01-15T09:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Query:
|
||||||
|
```sql
|
||||||
|
SELECT id, field_name, old_value, new_value, change_reason, changed_by, changed_at
|
||||||
|
FROM compliance_item_history
|
||||||
|
WHERE hostname = $1
|
||||||
|
ORDER BY changed_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/compliance/vcl/bulk-commit
|
||||||
|
|
||||||
|
**Behavior changes:**
|
||||||
|
|
||||||
|
Within the transaction, before updating each hostname:
|
||||||
|
1. Query current values for that hostname
|
||||||
|
2. Compare each field (resolution_date, remediation_plan) against the incoming value
|
||||||
|
3. Insert history rows for changed fields with `changed_by` set to `req.user.username`
|
||||||
|
|
||||||
|
No request/response shape changes. The `change_reason` is not supported for bulk updates (would require per-row reasons which adds complexity without clear value for mass imports).
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
### ComplianceDetailPanel.js
|
||||||
|
|
||||||
|
**New state:**
|
||||||
|
- `changeReason` (string) — text input value for the reason field
|
||||||
|
- `history` (array) — populated from the API response
|
||||||
|
|
||||||
|
**New UI elements:**
|
||||||
|
|
||||||
|
1. **Change Reason input** — a single-line text input placed between the remediation plan save button and the notes section. Cleared after each successful save.
|
||||||
|
|
||||||
|
2. **Change History section** — a new `<Section>` component placed between the Remediation Plan section and the Notes section. Displays up to 10 history entries in reverse chronological order.
|
||||||
|
|
||||||
|
**History entry display format:**
|
||||||
|
```
|
||||||
|
[field_name icon] old_value → new_value
|
||||||
|
username · 2026-01-20 [reason if present]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Resolution date entries: show dates as YYYY-MM-DD, NULL shown as "—"
|
||||||
|
- Remediation plan entries: truncate old/new values to 60 characters with "…" suffix; full text shown on hover via title attribute
|
||||||
|
- Change reason: displayed in muted text below the change line when present
|
||||||
|
|
||||||
|
**Save flow update:**
|
||||||
|
- The `handleSaveMetadata` function passes `change_reason` alongside the field values
|
||||||
|
- After successful save, clear the `changeReason` input and re-fetch detail (which now includes updated history)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If the history INSERT fails, the entire PATCH operation should fail (history is part of the same logical operation). Use a transaction wrapping both the history insert and the compliance_items update.
|
||||||
|
- If the history SELECT for the GET endpoint fails, return the device detail without history (graceful degradation) and log the error.
|
||||||
|
- `change_reason` validation: max 500 characters, trimmed. If over 500, return 400 with descriptive error.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
File: `backend/migrations/add_compliance_item_history.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting compliance_item_history migration...');
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS compliance_item_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
field_name TEXT NOT NULL CHECK (field_name IN ('resolution_date', 'remediation_plan')),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
change_reason TEXT,
|
||||||
|
changed_by TEXT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ compliance_item_history table created (or already exists)');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_hostname_field
|
||||||
|
ON compliance_item_history(hostname, field_name)
|
||||||
|
`);
|
||||||
|
console.log('✓ hostname/field_name index created');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_history_changed_at
|
||||||
|
ON compliance_item_history(changed_at)
|
||||||
|
`);
|
||||||
|
console.log('✓ changed_at index created');
|
||||||
|
|
||||||
|
console.log('Migration complete.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration failed:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reporting Isolation
|
||||||
|
|
||||||
|
No changes to VCL reporting queries. The following queries continue to read directly from `compliance_items.resolution_date`:
|
||||||
|
|
||||||
|
- Donut chart: `SELECT hostname, MAX(resolution_date) FROM compliance_items WHERE status = 'active' GROUP BY hostname`
|
||||||
|
- Burndown forecast: `SELECT resolution_date FROM compliance_items WHERE status = 'active' AND resolution_date IS NOT NULL`
|
||||||
|
- Per-vertical burndown in vclMultiVertical.js
|
||||||
|
|
||||||
|
The `compliance_item_history` table is never referenced by any reporting query.
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- History inserts are lightweight (single row per field change). Even bulk updates with 500 hostnames produce at most 1000 history rows per commit — well within PostgreSQL's transaction capacity.
|
||||||
|
- The LIMIT 10 on history retrieval prevents unbounded result sets for devices with many changes.
|
||||||
|
- Indexes on (hostname, field_name) and (changed_at) ensure fast lookups without full table scans.
|
||||||
|
- No additional queries are added to the VCL reporting paths.
|
||||||
98
.kiro/specs/remediation-plan-history/requirements.md
Normal file
98
.kiro/specs/remediation-plan-history/requirements.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Compliance_Item**: A row in the `compliance_items` table representing a single non-compliant device/metric pair.
|
||||||
|
- **Resolution_Date**: A DATE field on a compliance item indicating when remediation is expected to complete.
|
||||||
|
- **Remediation_Plan**: A TEXT field (max 2000 characters) describing the planned remediation approach.
|
||||||
|
- **History_Entry**: A row in the `compliance_item_history` table capturing a previous value of resolution_date or remediation_plan before it was overwritten.
|
||||||
|
- **Change_Reason**: An optional text field on a History_Entry describing why the change was made.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Persist History on Field Change
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want previous resolution dates and remediation plans to be preserved when I make changes, so that I have an audit trail of what was planned and when plans changed.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a user updates the resolution_date for a hostname via the metadata PATCH endpoint, THE History_Service SHALL insert a History_Entry containing the previous resolution_date value, the field name, the hostname, the timestamp of the change, the username of the user who made the change, and the change_reason if provided.
|
||||||
|
2. WHEN a user updates the remediation_plan for a hostname via the metadata PATCH endpoint, THE History_Service SHALL insert a History_Entry containing the previous remediation_plan value, the field name, the hostname, the timestamp of the change, the username of the user who made the change, and the change_reason if provided.
|
||||||
|
3. WHEN the previous value is NULL and the user sets a new value, THE History_Service SHALL insert a History_Entry with the old_value recorded as NULL.
|
||||||
|
4. WHEN the new value is identical to the current value, THE History_Service SHALL NOT create a History_Entry.
|
||||||
|
5. WHEN a bulk update changes resolution_date or remediation_plan for multiple hostnames, THE History_Service SHALL insert one History_Entry per hostname per changed field.
|
||||||
|
6. THE History_Service SHALL accept an optional change_reason field in the metadata PATCH request body.
|
||||||
|
|
||||||
|
### Requirement 2: History Storage Schema
|
||||||
|
|
||||||
|
**User Story:** As a system administrator, I want history entries stored in a dedicated table with proper indexing, so that history queries are fast and do not impact existing compliance_items queries.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Database SHALL store history entries in a `compliance_item_history` table with columns: id (serial primary key), hostname (text, not null), field_name (text, not null), old_value (text), new_value (text), change_reason (text), changed_by (text, not null), changed_at (timestamptz, default NOW()).
|
||||||
|
2. THE Database SHALL index the `compliance_item_history` table on (hostname, field_name) for efficient per-device history lookups.
|
||||||
|
3. THE Database SHALL index the `compliance_item_history` table on (changed_at) for chronological queries.
|
||||||
|
4. THE compliance_items table SHALL continue to store the current resolution_date and remediation_plan directly as columns, unchanged from the existing schema.
|
||||||
|
|
||||||
|
### Requirement 3: Reporting Isolation
|
||||||
|
|
||||||
|
**User Story:** As a VCL report consumer, I want burndown forecasts and donut charts to use only the current resolution_date, so that historical changes do not cause double-counting or incorrect projections.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE VCL_Report SHALL read resolution_date exclusively from the compliance_items table for burndown and donut calculations.
|
||||||
|
2. THE VCL_Report SHALL NOT join or reference the compliance_item_history table for any reporting query.
|
||||||
|
3. WHEN multiple History_Entries exist for a hostname, THE VCL_Report SHALL use only the Current_Value from compliance_items.resolution_date for forecasting.
|
||||||
|
|
||||||
|
### Requirement 4: History Retrieval API
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want to retrieve the change history for a device's resolution date and remediation plan, so that I can see who changed what and when.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a client requests the detail for a hostname, THE Compliance_API SHALL return the history of resolution_date and remediation_plan changes alongside the current values and notes.
|
||||||
|
2. THE Compliance_API SHALL return history entries sorted by changed_at in descending order (most recent first).
|
||||||
|
3. THE Compliance_API SHALL return a maximum of 10 history entries per hostname.
|
||||||
|
4. THE Compliance_API SHALL include the fields: field_name, old_value, new_value, change_reason, changed_by, and changed_at for each History_Entry.
|
||||||
|
5. IF no history entries exist for a hostname, THE Compliance_API SHALL return an empty array for the history field.
|
||||||
|
|
||||||
|
### Requirement 5: History Display in Detail Panel
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want to see the history of changes to resolution date and remediation plan in the device detail panel, so that I can understand how plans have evolved over time.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Detail_Panel SHALL display a "Change History" section showing all History_Entries for the selected hostname.
|
||||||
|
2. WHEN a History_Entry exists, THE Detail_Panel SHALL display the field that changed, the old value, the new value, who made the change, when the change occurred, and the change reason if one was provided.
|
||||||
|
3. THE Detail_Panel SHALL display history entries in reverse chronological order (most recent change at the top).
|
||||||
|
4. WHEN no history entries exist, THE Detail_Panel SHALL display a message indicating no changes have been recorded.
|
||||||
|
5. THE Detail_Panel SHALL format resolution_date values as YYYY-MM-DD and remediation_plan values as truncated text with a tooltip or expandable view for long entries.
|
||||||
|
6. THE Detail_Panel SHALL include a text input for change_reason when saving resolution_date or remediation_plan changes.
|
||||||
|
|
||||||
|
### Requirement 6: Bulk Update History Tracking
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want bulk xlsx updates to also track history, so that mass changes to resolution dates and remediation plans are auditable.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN the bulk update commit endpoint applies changes to resolution_date or remediation_plan, THE History_Service SHALL create History_Entries for each hostname where the value changed.
|
||||||
|
2. THE History_Service SHALL record the changed_by as the username of the user who initiated the bulk update.
|
||||||
|
3. WHEN a bulk update row contains the same value as the current value for a hostname, THE History_Service SHALL NOT create a History_Entry for that field.
|
||||||
|
|
||||||
|
### Requirement 7: Database Migration
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want the history table created via a standard migration script, so that it can be applied to existing deployments without manual intervention.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Migration SHALL create the `compliance_item_history` table if it does not already exist.
|
||||||
|
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.
|
||||||
51
.kiro/specs/remediation-plan-history/tasks.md
Normal file
51
.kiro/specs/remediation-plan-history/tasks.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Tasks — Remediation Plan History
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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
|
||||||
1
.kiro/specs/vcl-aggregated-burndown/.config.kiro
Normal file
1
.kiro/specs/vcl-aggregated-burndown/.config.kiro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"specId": "c717b04e-9452-4390-99bb-2c6871c1b9bd", "workflowType": "requirements-first", "specType": "feature"}
|
||||||
319
.kiro/specs/vcl-aggregated-burndown/design.md
Normal file
319
.kiro/specs/vcl-aggregated-burndown/design.md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# Design Document: VCL Aggregated Burndown
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds an aggregated (cross-vertical) burndown forecast to the CCP Metrics overview page. Currently, burndown data is only available per-vertical via `GET /api/compliance/vcl-multi/vertical/:code/burndown`. This feature introduces a new endpoint `GET /api/compliance/vcl-multi/burndown` that rolls up burndown data across all verticals, a new pure helper function `computeAggregatedBurndown` for testable computation logic, and a new `AggregatedBurndownChart` React component displayed on the overview page.
|
||||||
|
|
||||||
|
The design reuses the existing `computeVerticalBurndown` pattern from `vclHelpers.js` and extends it with hostname deduplication (a device appearing in multiple metrics counts once) and per-vertical contribution breakdown. The frontend component follows the same Recharts `BarChart` pattern used in the per-vertical burndown chart within `VerticalDetailView`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant FE as CCPMetricsPage
|
||||||
|
participant BE as Express Backend
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
|
||||||
|
Note over FE,DB: Overview Page Load (existing + new)
|
||||||
|
FE->>BE: GET /api/compliance/vcl-multi/stats
|
||||||
|
BE->>DB: Aggregate stats across verticals
|
||||||
|
BE-->>FE: { stats, donut, vertical_breakdown }
|
||||||
|
|
||||||
|
FE->>BE: GET /api/compliance/vcl-multi/trend
|
||||||
|
BE->>DB: Monthly snapshots
|
||||||
|
BE-->>FE: { months: [...] }
|
||||||
|
|
||||||
|
FE->>BE: GET /api/compliance/vcl-multi/burndown
|
||||||
|
BE->>DB: SELECT hostname, resolution_date, vertical FROM compliance_items WHERE vertical IS NOT NULL AND status = 'active'
|
||||||
|
BE->>BE: Deduplicate by hostname (earliest resolution_date)
|
||||||
|
BE->>BE: computeAggregatedBurndown(devices)
|
||||||
|
BE-->>FE: { total_non_compliant, blockers, with_dates, monthly_forecast, projected_clear_date, by_vertical }
|
||||||
|
|
||||||
|
FE->>FE: Render AggregatedBurndownChart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. **Query** — Fetch all active non-compliant devices across all verticals from `compliance_items`.
|
||||||
|
2. **Deduplicate** — Group by hostname, keeping the earliest non-null `resolution_date` across metric entries. A device appearing in 3 metrics counts as 1 device.
|
||||||
|
3. **Compute** — Pass deduplicated device list to `computeAggregatedBurndown` which produces totals, monthly buckets, cumulative projection, and per-vertical breakdown.
|
||||||
|
4. **Respond** — Return the computed burndown data to the frontend.
|
||||||
|
5. **Render** — `AggregatedBurndownChart` displays a bar chart of monthly remediations, summary stats header, and per-vertical contribution table.
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
#### New Endpoint
|
||||||
|
|
||||||
|
**`GET /api/compliance/vcl-multi/burndown`**
|
||||||
|
|
||||||
|
Returns aggregated burndown forecast across all verticals.
|
||||||
|
|
||||||
|
- Auth: `requireAuth()`
|
||||||
|
- Route file: `backend/routes/vclMultiVertical.js`
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_non_compliant": 400,
|
||||||
|
"blockers": 120,
|
||||||
|
"with_dates": 280,
|
||||||
|
"monthly_forecast": {
|
||||||
|
"2026-06": 85,
|
||||||
|
"2026-07": 110,
|
||||||
|
"2026-08": 55,
|
||||||
|
"2026-09": 30
|
||||||
|
},
|
||||||
|
"projected_clear_date": "2026-09",
|
||||||
|
"by_vertical": [
|
||||||
|
{ "vertical": "NTS_AEO", "total": 180, "blockers": 50, "with_dates": 130 },
|
||||||
|
{ "vertical": "SDIT_CISO", "total": 120, "blockers": 40, "with_dates": 80 },
|
||||||
|
{ "vertical": "TSI", "total": 100, "blockers": 30, "with_dates": 70 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When no active non-compliant devices exist:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_non_compliant": 0,
|
||||||
|
"blockers": 0,
|
||||||
|
"with_dates": 0,
|
||||||
|
"monthly_forecast": {},
|
||||||
|
"projected_clear_date": null,
|
||||||
|
"by_vertical": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### New Pure Helper Function
|
||||||
|
|
||||||
|
Added to `backend/helpers/vclHelpers.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Deduplicates devices by hostname, keeping the earliest non-null resolution_date.
|
||||||
|
* A device appearing in multiple metrics counts once.
|
||||||
|
*
|
||||||
|
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} items
|
||||||
|
* @returns {Array<{ hostname: string, resolution_date: string|null, vertical: string }>}
|
||||||
|
*/
|
||||||
|
function deduplicateByHostname(items) { ... }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes aggregated burndown from a deduplicated array of device objects.
|
||||||
|
* Each device has { hostname, resolution_date, vertical }.
|
||||||
|
*
|
||||||
|
* @param {Array<{ hostname: string, resolution_date: string|null, vertical: string }>} devices
|
||||||
|
* @returns {{
|
||||||
|
* total: number,
|
||||||
|
* blockers: number,
|
||||||
|
* with_dates: number,
|
||||||
|
* monthly: Object<string, number>,
|
||||||
|
* projection: Object<string, { remediated: number, remaining: number }>,
|
||||||
|
* projected_clear_date: string|null,
|
||||||
|
* by_vertical: Array<{ vertical: string, total: number, blockers: number, with_dates: number }>
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
function computeAggregatedBurndown(devices) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**`deduplicateByHostname` logic:**
|
||||||
|
- Groups items by hostname
|
||||||
|
- For each hostname, selects the earliest non-null `resolution_date` across all entries
|
||||||
|
- If all entries for a hostname have null dates, the device is a blocker
|
||||||
|
- Preserves the `vertical` from the first entry (for per-vertical breakdown, the endpoint groups separately)
|
||||||
|
|
||||||
|
**`computeAggregatedBurndown` logic:**
|
||||||
|
- Counts total devices, blockers (null date), and with_dates (non-null date)
|
||||||
|
- Buckets with_dates devices by YYYY-MM of resolution_date into `monthly`
|
||||||
|
- Sorts monthly keys chronologically
|
||||||
|
- Computes `projection` as cumulative remaining: starts at `total`, subtracts each month's count
|
||||||
|
- Sets `projected_clear_date` to the last month key if blockers = 0, otherwise null
|
||||||
|
- Groups devices by vertical for `by_vertical`, sorted descending by total, omitting verticals with zero devices
|
||||||
|
|
||||||
|
#### Endpoint Implementation Pattern
|
||||||
|
|
||||||
|
The endpoint follows the same pattern as the existing per-vertical burndown:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
router.get('/burndown', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT hostname, resolution_date, vertical
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE vertical IS NOT NULL AND status = 'active'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deduplicate by hostname (earliest non-null resolution_date)
|
||||||
|
const devices = deduplicateByHostname(rows);
|
||||||
|
const burndown = computeAggregatedBurndown(devices);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total_non_compliant: burndown.total,
|
||||||
|
blockers: burndown.blockers,
|
||||||
|
with_dates: burndown.with_dates,
|
||||||
|
monthly_forecast: burndown.monthly,
|
||||||
|
projected_clear_date: burndown.projected_clear_date,
|
||||||
|
by_vertical: burndown.by_vertical,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[VCL Multi] GET /burndown error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
#### New Component: `AggregatedBurndownChart`
|
||||||
|
|
||||||
|
Inline component within `CCPMetricsPage.js` (following the existing pattern where `StatsBar`, `DonutChart`, `TrendChart`, and `VerticalTable` are all defined in the same file).
|
||||||
|
|
||||||
|
**Placement:** Below the existing charts row (TrendChart + DonutChart), above the VerticalTable.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Fetches `GET /api/compliance/vcl-multi/burndown` on page load alongside existing stats/trend calls
|
||||||
|
- Displays a summary header with total non-compliant, blockers, in-progress, and projected clear date
|
||||||
|
- Renders a Recharts `BarChart` with one bar per monthly bucket (purple fill, matching existing burndown chart style)
|
||||||
|
- Below the chart, renders a compact per-vertical contribution table sorted by total descending
|
||||||
|
- Shows "No non-compliant devices" message when total = 0
|
||||||
|
- Shows "All X non-compliant devices lack remediation dates" when monthly_forecast is empty but blockers > 0
|
||||||
|
- Shows loading spinner while fetching
|
||||||
|
- Shows inline error message on API failure
|
||||||
|
|
||||||
|
**Chart specification:**
|
||||||
|
```javascript
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={monthlyData}>
|
||||||
|
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||||
|
<YAxis tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||||
|
<Tooltip contentStyle={{ background: '#1E293B', border: '1px solid #334155', borderRadius: '0.5rem', fontSize: '0.75rem' }} />
|
||||||
|
<Bar dataKey="count" fill="#A78BFA" fillOpacity={0.7} name="Projected Remediations" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
No schema changes required. The feature reads from the existing `compliance_items` table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Existing columns used:
|
||||||
|
-- hostname TEXT
|
||||||
|
-- vertical TEXT (nullable)
|
||||||
|
-- status TEXT ('active'|'resolved')
|
||||||
|
-- resolution_date DATE (nullable)
|
||||||
|
```
|
||||||
|
|
||||||
|
The query for the aggregated burndown:
|
||||||
|
```sql
|
||||||
|
SELECT hostname, resolution_date, vertical
|
||||||
|
FROM compliance_items
|
||||||
|
WHERE vertical IS NOT NULL AND status = 'active'
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the same data source used by the per-vertical burndown endpoint, just without the `vertical = $1` filter.
|
||||||
|
|
||||||
|
## 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: Partition Invariant
|
||||||
|
|
||||||
|
*For any* array of device objects passed to `computeAggregatedBurndown`, the result must satisfy `blockers + with_dates = total`. Every device is either a blocker (null resolution_date) or in-progress (non-null resolution_date), with no device uncounted or double-counted.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.2**
|
||||||
|
|
||||||
|
### Property 2: Monthly Bucket Conservation
|
||||||
|
|
||||||
|
*For any* array of device objects passed to `computeAggregatedBurndown`, the sum of all values in the `monthly` object must equal `with_dates`. Every in-progress device appears in exactly one monthly bucket, and no device is lost or duplicated during bucketing.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.3, 1.5**
|
||||||
|
|
||||||
|
### Property 3: Chronological Monthly Ordering
|
||||||
|
|
||||||
|
*For any* array of device objects passed to `computeAggregatedBurndown`, the keys of the `monthly` object must be in ascending chronological order (lexicographic sort of YYYY-MM strings).
|
||||||
|
|
||||||
|
**Validates: Requirements 2.4**
|
||||||
|
|
||||||
|
### Property 4: Cumulative Projection Consistency
|
||||||
|
|
||||||
|
*For any* array of device objects passed to `computeAggregatedBurndown`, the `projection` object must satisfy: for each month in chronological order, `projection[month].remaining = total - (cumulative sum of monthly[m] for all m <= month)`. The first month's remaining equals `total - monthly[first_month]`.
|
||||||
|
|
||||||
|
**Validates: Requirements 2.5**
|
||||||
|
|
||||||
|
### Property 5: Projected Clear Date Logic
|
||||||
|
|
||||||
|
*For any* array of device objects passed to `computeAggregatedBurndown`: if `blockers > 0`, then `projected_clear_date` must be `null`; if `blockers = 0` and `with_dates > 0`, then `projected_clear_date` must equal the last (chronologically greatest) key in `monthly`.
|
||||||
|
|
||||||
|
**Validates: Requirements 1.7**
|
||||||
|
|
||||||
|
### Property 6: Hostname Deduplication with Earliest Date
|
||||||
|
|
||||||
|
*For any* array of items where the same hostname appears multiple times with different resolution_dates, `deduplicateByHostname` must produce exactly one entry per unique hostname, and that entry's `resolution_date` must be the earliest non-null date among all entries for that hostname (or null if all entries have null dates).
|
||||||
|
|
||||||
|
**Validates: Requirements 1.6**
|
||||||
|
|
||||||
|
### Property 7: Aggregation Consistency with Per-Vertical Computation
|
||||||
|
|
||||||
|
*For any* array of device objects spanning multiple verticals, the aggregated `total` must equal the sum of per-vertical totals, the aggregated `blockers` must equal the sum of per-vertical blockers, the aggregated `with_dates` must equal the sum of per-vertical with_dates, and for each month key, the aggregated monthly count must equal the sum of that month's count across all per-vertical computations.
|
||||||
|
|
||||||
|
**Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
||||||
|
|
||||||
|
### Property 8: By-Vertical Sorting and Filtering
|
||||||
|
|
||||||
|
*For any* array of device objects spanning multiple verticals, the `by_vertical` array must be sorted in descending order by `total`, must not contain any entry where `total = 0`, and the sum of all `by_vertical[i].total` must equal the overall `total`.
|
||||||
|
|
||||||
|
**Validates: Requirements 5.1, 5.2, 5.4**
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Condition | HTTP Status | Response | Behavior |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Database error | 500 | `{ "error": "Database error" }` | Log error, return 500 |
|
||||||
|
| Unauthenticated request | 401 | `{ "error": "Authentication required" }` | Middleware rejects |
|
||||||
|
| No active non-compliant devices | 200 | Zero/empty response (see above) | Graceful empty state |
|
||||||
|
|
||||||
|
Frontend error handling:
|
||||||
|
- API failure: inline error message in red monospace text, consistent with existing error patterns on the page
|
||||||
|
- Loading state: `<Loader />` spinner with "Loading..." text
|
||||||
|
- Empty state (total = 0): informational message instead of empty chart
|
||||||
|
- All blockers (monthly empty, blockers > 0): message indicating all devices lack dates
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Property-Based Testing
|
||||||
|
|
||||||
|
Use `fast-check` (already used in this project). Each correctness property maps to a single property-based test with minimum 100 iterations.
|
||||||
|
|
||||||
|
Property tests target the pure helper functions exported from `backend/helpers/vclHelpers.js`:
|
||||||
|
- `deduplicateByHostname` — Property 6
|
||||||
|
- `computeAggregatedBurndown` — Properties 1, 2, 3, 4, 5, 7, 8
|
||||||
|
|
||||||
|
Tag format: **Feature: vcl-aggregated-burndown, Property {number}: {title}**
|
||||||
|
|
||||||
|
Test file: `backend/__tests__/vcl-aggregated-burndown.property.test.js`
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
Unit tests cover specific examples and edge cases:
|
||||||
|
|
||||||
|
- **Empty input** — verify all-zero response (Requirement 2.6)
|
||||||
|
- **All blockers** — verify with_dates = 0, monthly = {}, projected_clear_date = null (Requirement 2.7)
|
||||||
|
- **Single device, single metric** — verify basic computation
|
||||||
|
- **Duplicate hostnames across metrics** — verify deduplication picks earliest date
|
||||||
|
- **Duplicate hostnames where all dates are null** — verify device is a blocker
|
||||||
|
- **API endpoint integration** — verify response shape with mocked DB data
|
||||||
|
- **Auth middleware** — verify 401 without session
|
||||||
|
|
||||||
|
Test file: `backend/__tests__/vcl-aggregated-burndown.test.js`
|
||||||
|
|
||||||
|
### Frontend Testing
|
||||||
|
|
||||||
|
- Component renders loading state
|
||||||
|
- Component renders empty state message when total = 0
|
||||||
|
- Component renders blocker-only message when monthly is empty
|
||||||
|
- Component renders bar chart with correct data
|
||||||
|
- Component renders per-vertical table sorted correctly
|
||||||
|
- Component renders error message on API failure
|
||||||
84
.kiro/specs/vcl-aggregated-burndown/requirements.md
Normal file
84
.kiro/specs/vcl-aggregated-burndown/requirements.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
This feature adds an aggregated (cross-vertical) burndown forecast chart to the main CCP Metrics overview page. Currently, burndown forecasts only exist at the per-vertical drill-down level — when a user clicks into a specific vertical (e.g., NTS_AEO), they see that vertical's burndown showing blockers, devices with dates, and monthly projected remediation. This feature rolls up the same burndown concept across ALL verticals and displays it on the overview page alongside the existing Stats Bar, Compliance Trend chart, and Non-Compliant Status donut chart.
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Aggregated_Burndown_API**: The backend endpoint that computes burndown forecast data across all verticals by querying active non-compliant devices from `compliance_items` where `vertical IS NOT NULL`.
|
||||||
|
- **Burndown_Chart**: A Recharts-based bar chart component that visualizes monthly projected remediation counts and cumulative remaining non-compliant devices across all verticals.
|
||||||
|
- **Blocker**: A non-compliant device in `compliance_items` with `status = 'active'` and `resolution_date IS NULL` — no committed remediation timeline exists.
|
||||||
|
- **In_Progress_Device**: A non-compliant device in `compliance_items` with `status = 'active'` and a non-null `resolution_date` — a target remediation date has been set.
|
||||||
|
- **Monthly_Bucket**: A grouping of in-progress devices by the month portion (YYYY-MM) of their `resolution_date`, representing how many devices are expected to be remediated in that month.
|
||||||
|
- **Projected_Clear_Date**: The earliest month by which all in-progress devices (those with resolution dates) are projected to be remediated, assuming blockers remain unresolved.
|
||||||
|
- **Overview_Page**: The main CCP Metrics page (`CCPMetricsPage.js`) that displays aggregated cross-vertical statistics before any vertical drill-down is selected.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1: Aggregated Burndown API Endpoint
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want a single API endpoint that returns burndown forecast data aggregated across all verticals, so that the overview page can display a cross-organizational remediation projection without requiring multiple per-vertical API calls.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN a GET request is made to `/api/compliance/vcl-multi/burndown`, THE Aggregated_Burndown_API SHALL return a JSON response containing `total_non_compliant`, `blockers`, `with_dates`, `monthly_forecast`, and `projected_clear_date` fields.
|
||||||
|
2. THE Aggregated_Burndown_API SHALL compute `total_non_compliant` as the count of distinct hostnames in `compliance_items` where `vertical IS NOT NULL` and `status = 'active'`.
|
||||||
|
3. THE Aggregated_Burndown_API SHALL compute `blockers` as the count of distinct hostnames where `resolution_date IS NULL` among active non-compliant devices across all verticals.
|
||||||
|
4. THE Aggregated_Burndown_API SHALL compute `with_dates` as the count of distinct hostnames where `resolution_date IS NOT NULL` among active non-compliant devices across all verticals.
|
||||||
|
5. THE Aggregated_Burndown_API SHALL compute `monthly_forecast` by bucketing in-progress devices into Monthly_Buckets based on the YYYY-MM portion of their `resolution_date`, with each bucket containing the count of devices projected to be remediated in that month.
|
||||||
|
6. THE Aggregated_Burndown_API SHALL deduplicate devices by hostname before computing burndown totals — a device appearing in multiple metrics counts once, using the earliest non-null `resolution_date` across its metric entries.
|
||||||
|
7. THE Aggregated_Burndown_API SHALL return `projected_clear_date` as the last month in the sorted monthly forecast when all in-progress devices have been accounted for, or `null` when blockers exist.
|
||||||
|
8. WHEN no active non-compliant devices exist across any vertical, THE Aggregated_Burndown_API SHALL return `total_non_compliant: 0`, `blockers: 0`, `with_dates: 0`, `monthly_forecast: {}`, and `projected_clear_date: null`.
|
||||||
|
9. THE Aggregated_Burndown_API SHALL require authentication via `requireAuth()` middleware.
|
||||||
|
|
||||||
|
### Requirement 2: Aggregated Burndown Computation Logic
|
||||||
|
|
||||||
|
**User Story:** As a developer, I want a pure helper function that computes aggregated burndown from a set of device objects, so that the computation is testable in isolation and reusable.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE `computeAggregatedBurndown` helper function SHALL accept an array of device objects (each with `hostname` and `resolution_date` fields) and return an object with `total`, `blockers`, `with_dates`, `monthly`, `projection`, and `projected_clear_date` fields.
|
||||||
|
2. FOR ALL valid input arrays, THE `computeAggregatedBurndown` function SHALL satisfy the invariant: `blockers + with_dates = total`.
|
||||||
|
3. FOR ALL valid input arrays, THE `computeAggregatedBurndown` function SHALL satisfy the invariant: the sum of all values in `monthly` equals `with_dates`.
|
||||||
|
4. THE `computeAggregatedBurndown` function SHALL produce `monthly` buckets sorted chronologically by month key (YYYY-MM format).
|
||||||
|
5. THE `computeAggregatedBurndown` function SHALL compute `projection` as a cumulative remaining count per month — starting from `total` and subtracting each month's remediated count in chronological order.
|
||||||
|
6. WHEN the input array is empty, THE `computeAggregatedBurndown` function SHALL return `total: 0`, `blockers: 0`, `with_dates: 0`, `monthly: {}`, `projection: {}`, and `projected_clear_date: null`.
|
||||||
|
7. WHEN all devices have `resolution_date = null`, THE `computeAggregatedBurndown` function SHALL return `blockers` equal to `total`, `with_dates: 0`, `monthly: {}`, and `projected_clear_date: null`.
|
||||||
|
|
||||||
|
### Requirement 3: Burndown Chart Frontend Component
|
||||||
|
|
||||||
|
**User Story:** As a senior leader viewing the CCP Metrics overview page, I want to see an aggregated burndown forecast chart showing when non-compliant devices across all verticals are projected to be remediated, so that I can assess organizational remediation progress at a glance.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Burndown_Chart SHALL be displayed on the Overview_Page below the Compliance Trend chart and Non-Compliant Status donut chart section.
|
||||||
|
2. THE Burndown_Chart SHALL display a bar chart with one bar per Monthly_Bucket, where the bar height represents the count of devices projected to be remediated in that month.
|
||||||
|
3. THE Burndown_Chart SHALL display a summary header showing the total non-compliant count, blocker count, in-progress count, and projected clear date.
|
||||||
|
4. THE Burndown_Chart SHALL use the Recharts `BarChart` component with styling consistent with the existing Compliance Trend chart (dark background, teal/purple color palette, monospace axis labels).
|
||||||
|
5. WHEN the Aggregated_Burndown_API returns `total_non_compliant: 0`, THE Burndown_Chart SHALL display a message indicating no non-compliant devices exist rather than rendering an empty chart.
|
||||||
|
6. WHEN the Aggregated_Burndown_API returns `monthly_forecast` with zero entries but `blockers > 0`, THE Burndown_Chart SHALL display the blocker count with a message indicating all non-compliant devices lack remediation dates.
|
||||||
|
7. THE Burndown_Chart SHALL fetch data from the Aggregated_Burndown_API on page load and display a loading indicator while the request is in flight.
|
||||||
|
8. IF the Aggregated_Burndown_API request fails, THEN THE Burndown_Chart SHALL display an inline error message consistent with the existing error display pattern on the Overview_Page.
|
||||||
|
|
||||||
|
### Requirement 4: Burndown Data Consistency
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want the aggregated burndown numbers to be consistent with the per-vertical burndown data, so that I can trust the overview numbers match the sum of individual verticals.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. FOR ALL sets of active non-compliant devices, THE Aggregated_Burndown_API `total_non_compliant` SHALL equal the sum of `total_non_compliant` values returned by each individual per-vertical burndown endpoint (`GET /api/compliance/vcl-multi/vertical/:code/burndown`).
|
||||||
|
2. FOR ALL sets of active non-compliant devices, THE Aggregated_Burndown_API `blockers` SHALL equal the sum of `blockers` values returned by each individual per-vertical burndown endpoint.
|
||||||
|
3. FOR ALL sets of active non-compliant devices, THE Aggregated_Burndown_API `with_dates` SHALL equal the sum of `with_dates` values returned by each individual per-vertical burndown endpoint.
|
||||||
|
4. FOR ALL monthly buckets, THE Aggregated_Burndown_API monthly forecast count for a given month SHALL equal the sum of that month's forecast count across all per-vertical burndown responses.
|
||||||
|
|
||||||
|
### Requirement 5: Per-Vertical Contribution Breakdown
|
||||||
|
|
||||||
|
**User Story:** As a compliance analyst, I want to see which verticals contribute the most to the aggregated burndown, so that I can identify which organizations need the most attention for remediation planning.
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. THE Aggregated_Burndown_API response SHALL include a `by_vertical` array containing per-vertical breakdowns with fields: `vertical`, `total`, `blockers`, `with_dates`.
|
||||||
|
2. THE `by_vertical` array SHALL be sorted in descending order by `total` (most non-compliant devices first).
|
||||||
|
3. THE Burndown_Chart component SHALL display the per-vertical breakdown below the bar chart as a compact summary table showing each vertical's contribution to the overall burndown.
|
||||||
|
4. WHEN a vertical has zero active non-compliant devices, THE Aggregated_Burndown_API SHALL omit that vertical from the `by_vertical` array.
|
||||||
113
.kiro/specs/vcl-aggregated-burndown/tasks.md
Normal file
113
.kiro/specs/vcl-aggregated-burndown/tasks.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Implementation Plan: VCL Aggregated Burndown
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement an aggregated cross-vertical burndown forecast feature consisting of: two new pure helper functions (`deduplicateByHostname` and `computeAggregatedBurndown`) in `vclHelpers.js`, a new `GET /api/compliance/vcl-multi/burndown` endpoint in the existing `vclMultiVertical.js` route file, property-based tests validating 8 correctness properties, unit tests covering edge cases and API integration, and an `AggregatedBurndownChart` inline component in `CCPMetricsPage.js`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. Implement backend helper functions
|
||||||
|
- [x] 1.1 Add `deduplicateByHostname` function to `backend/helpers/vclHelpers.js`
|
||||||
|
- Groups items by hostname
|
||||||
|
- For each hostname, selects the earliest non-null `resolution_date` across all entries
|
||||||
|
- If all entries for a hostname have null dates, the device is a blocker (null date preserved)
|
||||||
|
- Preserves the `vertical` from the first entry for that hostname
|
||||||
|
- Export the function from the module
|
||||||
|
- _Requirements: 1.6_
|
||||||
|
|
||||||
|
- [x] 1.2 Add `computeAggregatedBurndown` function to `backend/helpers/vclHelpers.js`
|
||||||
|
- Accepts an array of device objects with `hostname`, `resolution_date`, and `vertical` fields
|
||||||
|
- Counts total devices, blockers (null date), and with_dates (non-null date)
|
||||||
|
- Buckets with_dates devices by YYYY-MM of resolution_date into `monthly` object
|
||||||
|
- Sorts monthly keys chronologically
|
||||||
|
- Computes `projection` as cumulative remaining: starts at `total`, subtracts each month's count
|
||||||
|
- Sets `projected_clear_date` to the last month key if blockers = 0, otherwise null
|
||||||
|
- Groups devices by vertical for `by_vertical`, sorted descending by total, omitting verticals with zero devices
|
||||||
|
- Each `by_vertical` entry has `{ vertical, total, blockers, with_dates }`
|
||||||
|
- Returns `{ total, blockers, with_dates, monthly, projection, projected_clear_date, by_vertical }`
|
||||||
|
- Export the function from the module
|
||||||
|
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 5.1, 5.2, 5.4_
|
||||||
|
|
||||||
|
- [x]* 1.3 Write property tests for `deduplicateByHostname` and `computeAggregatedBurndown`
|
||||||
|
- **Property 1: Partition Invariant** — `blockers + with_dates = total` for any input
|
||||||
|
- **Validates: Requirements 2.2**
|
||||||
|
- **Property 2: Monthly Bucket Conservation** — sum of monthly values = with_dates
|
||||||
|
- **Validates: Requirements 2.3, 1.5**
|
||||||
|
- **Property 3: Chronological Monthly Ordering** — monthly keys in ascending YYYY-MM order
|
||||||
|
- **Validates: Requirements 2.4**
|
||||||
|
- **Property 4: Cumulative Projection Consistency** — projection[month].remaining = total - cumulative sum
|
||||||
|
- **Validates: Requirements 2.5**
|
||||||
|
- **Property 5: Projected Clear Date Logic** — null when blockers > 0, last month key when blockers = 0
|
||||||
|
- **Validates: Requirements 1.7**
|
||||||
|
- **Property 6: Hostname Deduplication with Earliest Date** — one entry per hostname, earliest non-null date
|
||||||
|
- **Validates: Requirements 1.6**
|
||||||
|
- **Property 7: Aggregation Consistency with Per-Vertical Computation** — aggregated totals = sum of per-vertical totals
|
||||||
|
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4**
|
||||||
|
- **Property 8: By-Vertical Sorting and Filtering** — sorted descending by total, no zero-total entries, sum = overall total
|
||||||
|
- **Validates: Requirements 5.1, 5.2, 5.4**
|
||||||
|
- Test file: `backend/__tests__/vcl-aggregated-burndown.property.test.js`
|
||||||
|
|
||||||
|
- [x] 2. Implement backend API endpoint
|
||||||
|
- [x] 2.1 Add `GET /burndown` route to `backend/routes/vclMultiVertical.js`
|
||||||
|
- Query `compliance_items` for all active non-compliant devices across verticals
|
||||||
|
- Call `deduplicateByHostname` on the query results
|
||||||
|
- Call `computeAggregatedBurndown` on the deduplicated devices
|
||||||
|
- Map the result to the API response shape: `{ total_non_compliant, blockers, with_dates, monthly_forecast, projected_clear_date, by_vertical }`
|
||||||
|
- Handle database errors with 500 status and `{ error: "Database error" }`
|
||||||
|
- Route is protected by `requireAuth()` (already applied via `router.use`)
|
||||||
|
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9_
|
||||||
|
|
||||||
|
- [x]* 2.2 Write unit tests for the burndown endpoint
|
||||||
|
- Test empty DB returns zero/empty response (Requirement 1.8)
|
||||||
|
- Test all-blocker scenario returns with_dates=0, monthly={}, projected_clear_date=null (Requirement 2.7)
|
||||||
|
- Test single device single metric basic computation
|
||||||
|
- Test duplicate hostnames across metrics — verify deduplication picks earliest date
|
||||||
|
- Test duplicate hostnames where all dates are null — verify device is a blocker
|
||||||
|
- Test response shape matches API contract
|
||||||
|
- Test 401 without auth session (mock requireAuth to reject)
|
||||||
|
- Test file: `backend/__tests__/vcl-aggregated-burndown.test.js`
|
||||||
|
- _Requirements: 1.1, 1.8, 1.9, 2.6, 2.7_
|
||||||
|
|
||||||
|
- [x] 3. Checkpoint - Ensure all backend tests pass
|
||||||
|
- Ensure all tests pass, ask the user if questions arise.
|
||||||
|
|
||||||
|
- [x] 4. Implement frontend component
|
||||||
|
- [x] 4.1 Add `AggregatedBurndownChart` component to `frontend/src/components/pages/CCPMetricsPage.js`
|
||||||
|
- Add as an inline component following the existing pattern (StatsBar, DonutChart, TrendChart are all in the same file)
|
||||||
|
- Fetch `GET /api/compliance/vcl-multi/burndown` on page load alongside existing stats/trend calls
|
||||||
|
- Display summary header with total non-compliant, blockers, in-progress, and projected clear date
|
||||||
|
- Render a Recharts `BarChart` with one bar per monthly bucket (purple fill `#A78BFA`, fillOpacity 0.7)
|
||||||
|
- Below the chart, render a compact per-vertical contribution table sorted by total descending
|
||||||
|
- Show "No non-compliant devices" message when total = 0
|
||||||
|
- Show "All X non-compliant devices lack remediation dates" when monthly_forecast is empty but blockers > 0
|
||||||
|
- Show `<Loader />` spinner while fetching
|
||||||
|
- Show inline error message on API failure
|
||||||
|
- Place the component below the charts row (TrendChart + DonutChart), above the VerticalTable
|
||||||
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 5.3_
|
||||||
|
|
||||||
|
- [x] 5. 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
|
||||||
|
- Each task references specific requirements for traceability
|
||||||
|
- Checkpoints ensure incremental validation
|
||||||
|
- Property tests validate universal correctness properties from the design document
|
||||||
|
- Unit tests validate specific examples and edge cases
|
||||||
|
- The design uses JavaScript — all implementations use Node.js/Express (backend) and React 19 (frontend)
|
||||||
|
- The `vclMultiVertical.js` route file already exists with `router.use(requireAuth())` applied globally
|
||||||
|
|
||||||
|
## Task Dependency Graph
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"waves": [
|
||||||
|
{ "id": 0, "tasks": ["1.1"] },
|
||||||
|
{ "id": 1, "tasks": ["1.2"] },
|
||||||
|
{ "id": 2, "tasks": ["1.3", "2.1"] },
|
||||||
|
{ "id": 3, "tasks": ["2.2"] },
|
||||||
|
{ "id": 4, "tasks": ["4.1"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
537
.kiro/specs/vcl-multi-vertical-upload/design.md
Normal file
537
.kiro/specs/vcl-multi-vertical-upload/design.md
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
# Design Document: VCL Multi-Vertical Upload
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds a multi-file upload flow to the VCL reporting page that accepts per-vertical compliance xlsx files, stores them with vertical-scoped resolution logic, and generates cross-organizational executive reports with drill-down capability by vertical and metric. It is designed as a POC for the compliance team to evaluate before eventual CyberMetrics API integration.
|
||||||
|
|
||||||
|
The feature is architecturally separate from the existing single-file AEO compliance upload. It reuses the same Python parser and database schema (with additions), but introduces vertical-scoped commit logic and a new set of API endpoints prefixed with `/api/compliance/vcl-multi/`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Compliance Analyst
|
||||||
|
participant FE as React Frontend
|
||||||
|
participant BE as Express Backend
|
||||||
|
participant PY as Python Parser
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
|
||||||
|
Note over U,DB: Multi-File Upload Flow
|
||||||
|
U->>FE: Drop/select 1–14 xlsx files
|
||||||
|
FE->>FE: Extract vertical + date from each filename
|
||||||
|
FE->>BE: POST /api/compliance/vcl-multi/preview (multipart, multiple files)
|
||||||
|
|
||||||
|
loop For each file
|
||||||
|
BE->>PY: parse_compliance_xlsx.py <file>
|
||||||
|
PY-->>BE: { items, summary, report_date, total }
|
||||||
|
BE->>DB: Query active items WHERE vertical = X
|
||||||
|
BE->>BE: Compute scoped diff (new/recurring/resolved within vertical)
|
||||||
|
end
|
||||||
|
|
||||||
|
BE-->>FE: { files: [{ vertical, date, diff, itemCount, tempFile }] }
|
||||||
|
FE->>FE: Display batch preview table
|
||||||
|
U->>FE: Confirm batch
|
||||||
|
FE->>BE: POST /api/compliance/vcl-multi/commit { files: [...] }
|
||||||
|
|
||||||
|
loop For each file (single transaction)
|
||||||
|
BE->>DB: Upsert items for vertical X
|
||||||
|
BE->>DB: Resolve missing items WHERE vertical = X only
|
||||||
|
BE->>DB: Update/create snapshot for vertical X
|
||||||
|
end
|
||||||
|
|
||||||
|
BE-->>FE: { committed: [...] }
|
||||||
|
|
||||||
|
Note over FE,DB: VCL Multi-Vertical Report Load
|
||||||
|
FE->>BE: GET /api/compliance/vcl-multi/stats
|
||||||
|
BE->>DB: Aggregate across all verticals
|
||||||
|
BE-->>FE: { stats, verticalBreakdown, donut }
|
||||||
|
|
||||||
|
FE->>BE: GET /api/compliance/vcl-multi/vertical/:code/metrics
|
||||||
|
BE->>DB: Per-metric breakdown for vertical
|
||||||
|
BE-->>FE: { metrics: [...] }
|
||||||
|
|
||||||
|
FE->>BE: GET /api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices
|
||||||
|
BE->>DB: Device list for vertical + metric
|
||||||
|
BE-->>FE: { devices: [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow Summary
|
||||||
|
|
||||||
|
1. **Upload** — Multiple files uploaded simultaneously. Each file is parsed independently. Vertical identity comes from the filename, not from inside the xlsx.
|
||||||
|
2. **Scoped resolution** — Each file's commit only resolves items within its own vertical. Other verticals are untouched.
|
||||||
|
3. **Aggregation** — VCL stats endpoints aggregate across all verticals for the executive view.
|
||||||
|
4. **Drill-down** — Vertical → Metric → Device hierarchy for investigation.
|
||||||
|
5. **Burndown** — Computed from `resolution_date` values on non-compliant devices, bucketed by month per vertical.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Schema Changes
|
||||||
|
|
||||||
|
#### New column on `compliance_items`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical ON compliance_items(vertical);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_status ON compliance_items(vertical, status);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `vertical` column stores the organizational vertical code (NTS_AEO, SDIT_CISO, etc.) extracted from the filename at upload time. Existing items (from the old single-file flow) will have `vertical = NULL` — they continue to work with the existing AEO compliance page unchanged.
|
||||||
|
|
||||||
|
#### New column on `compliance_uploads`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE compliance_uploads ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags each upload record with its vertical so we can query upload history per vertical.
|
||||||
|
|
||||||
|
#### New table: `vcl_multi_vertical_summary`
|
||||||
|
|
||||||
|
Stores the parsed Summary sheet data per vertical per upload for metric-level reporting.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS vcl_multi_vertical_summary (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||||
|
vertical TEXT NOT NULL,
|
||||||
|
metric_id TEXT NOT NULL,
|
||||||
|
metric_desc TEXT DEFAULT '',
|
||||||
|
category TEXT DEFAULT 'Other',
|
||||||
|
team TEXT DEFAULT '',
|
||||||
|
priority TEXT DEFAULT '',
|
||||||
|
non_compliant INTEGER DEFAULT 0,
|
||||||
|
compliant INTEGER DEFAULT 0,
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
compliance_pct NUMERIC(5,2) DEFAULT 0,
|
||||||
|
target NUMERIC(5,2) DEFAULT 0,
|
||||||
|
status TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical
|
||||||
|
ON vcl_multi_vertical_summary(vertical);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_upload
|
||||||
|
ON vcl_multi_vertical_summary(upload_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Updated `compliance_snapshots`
|
||||||
|
|
||||||
|
The existing snapshots table already has a `vertical` column. Multi-vertical uploads will create snapshots keyed on the vertical code (NTS_AEO, SDIT_CISO) rather than the team name (STEAM, ACCESS-ENG). An additional `_ALL` aggregate snapshot is created for the trend chart.
|
||||||
|
|
||||||
|
### Entity Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
compliance_uploads (1) ──── (N) compliance_items
|
||||||
|
│ │
|
||||||
|
│ vertical │ vertical
|
||||||
|
│ │
|
||||||
|
└──── (N) vcl_multi_vertical_summary
|
||||||
|
│
|
||||||
|
compliance_snapshots ─────────────────┘ (keyed on vertical + month)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vertical Identification Logic
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Extracts vertical code and report date from a filename.
|
||||||
|
* Pattern: <VERTICAL>_YYYY_MM_DD.xlsx
|
||||||
|
* Examples:
|
||||||
|
* NTS_AEO_2026_05_11.xlsx → { vertical: 'NTS_AEO', date: '2026-05-11' }
|
||||||
|
* SDIT_CISO_2026_05_11.xlsx → { vertical: 'SDIT_CISO', date: '2026-05-11' }
|
||||||
|
* SR_2026_05_11.xlsx → { vertical: 'SR', date: '2026-05-11' }
|
||||||
|
* AllOthers_2026_05_11.xlsx → { vertical: 'AllOthers', date: '2026-05-11' }
|
||||||
|
*/
|
||||||
|
function parseVerticalFilename(filename) {
|
||||||
|
const stem = filename.replace(/\.xlsx$/i, '');
|
||||||
|
const match = stem.match(/^(.+?)_(\d{4})_(\d{2})_(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
vertical: match[1],
|
||||||
|
date: `${match[2]}-${match[3]}-${match[4]}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Upload Flow
|
||||||
|
|
||||||
|
**`POST /api/compliance/vcl-multi/preview`**
|
||||||
|
|
||||||
|
Accepts multiple xlsx files via multipart form data. Parses each, computes per-vertical scoped diffs.
|
||||||
|
|
||||||
|
- Auth: `requireAuth()`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Body: multipart/form-data with field `files` (array of xlsx files)
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"filename": "NTS_AEO_2026_05_11.xlsx",
|
||||||
|
"vertical": "NTS_AEO",
|
||||||
|
"report_date": "2026-05-11",
|
||||||
|
"total_items": 342,
|
||||||
|
"diff": { "new_count": 12, "recurring_count": 320, "resolved_count": 8 },
|
||||||
|
"summary_entries": 24,
|
||||||
|
"tempFile": "/path/to/temp.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unrecognized": ["weird_file.xlsx"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`POST /api/compliance/vcl-multi/commit`**
|
||||||
|
|
||||||
|
Commits all previewed files in a single transaction.
|
||||||
|
|
||||||
|
- Auth: `requireAuth()`, `requireGroup('Admin', 'Standard_User')`
|
||||||
|
- Body: `{ files: [{ tempFile, vertical, report_date, filename }] }`
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"committed": [
|
||||||
|
{ "vertical": "NTS_AEO", "upload_id": 45, "new_count": 12, "recurring_count": 320, "resolved_count": 8 }
|
||||||
|
],
|
||||||
|
"total_new": 85,
|
||||||
|
"total_resolved": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reporting
|
||||||
|
|
||||||
|
**`GET /api/compliance/vcl-multi/stats`**
|
||||||
|
|
||||||
|
Aggregated cross-vertical executive summary.
|
||||||
|
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stats": {
|
||||||
|
"total_devices": 4200,
|
||||||
|
"compliant": 3800,
|
||||||
|
"non_compliant": 400,
|
||||||
|
"compliance_pct": 90,
|
||||||
|
"target_pct": 95
|
||||||
|
},
|
||||||
|
"donut": {
|
||||||
|
"blocked": { "count": 120, "pct": 30 },
|
||||||
|
"in_progress": { "count": 280, "pct": 70 }
|
||||||
|
},
|
||||||
|
"vertical_breakdown": [
|
||||||
|
{
|
||||||
|
"vertical": "NTS_AEO",
|
||||||
|
"total_devices": 800,
|
||||||
|
"compliant": 720,
|
||||||
|
"non_compliant": 80,
|
||||||
|
"compliance_pct": 90,
|
||||||
|
"blockers": 25,
|
||||||
|
"forecast_burndown": { "2026-06": 20, "2026-07": 35, "2026-08": 15 },
|
||||||
|
"last_upload": "2026-05-11"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_upload_date": "2026-05-11"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`GET /api/compliance/vcl-multi/trend`**
|
||||||
|
|
||||||
|
Monthly trend data for the overview chart.
|
||||||
|
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"months": [
|
||||||
|
{
|
||||||
|
"month": "2026-03",
|
||||||
|
"compliance_pct": 85,
|
||||||
|
"compliant": 3400,
|
||||||
|
"non_compliant": 600,
|
||||||
|
"forecast_pct": null,
|
||||||
|
"target_pct": 95
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`GET /api/compliance/vcl-multi/vertical/:code/metrics`**
|
||||||
|
|
||||||
|
Per-metric breakdown for a specific vertical.
|
||||||
|
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vertical": "NTS_AEO",
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"metric_id": "5.2.4",
|
||||||
|
"metric_desc": "MFA enforcement on privileged accounts",
|
||||||
|
"category": "Access & MFA",
|
||||||
|
"non_compliant": 15,
|
||||||
|
"compliant": 785,
|
||||||
|
"total": 800,
|
||||||
|
"compliance_pct": 98.1,
|
||||||
|
"target": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
{ "category": "Access & MFA", "non_compliant": 45, "compliance_pct": 94.4 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`GET /api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices`**
|
||||||
|
|
||||||
|
Device list for a specific vertical + metric combination.
|
||||||
|
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"hostname": "srv-nts-001",
|
||||||
|
"ip_address": "10.1.2.3",
|
||||||
|
"device_type": "Router",
|
||||||
|
"team": "STEAM",
|
||||||
|
"seen_count": 4,
|
||||||
|
"first_seen": "2026-03-15",
|
||||||
|
"last_seen": "2026-05-11",
|
||||||
|
"resolution_date": "2026-07-01",
|
||||||
|
"remediation_plan": "Scheduled for next maintenance window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`GET /api/compliance/vcl-multi/vertical/:code/burndown`**
|
||||||
|
|
||||||
|
Burndown forecast for a specific vertical.
|
||||||
|
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vertical": "NTS_AEO",
|
||||||
|
"total_non_compliant": 80,
|
||||||
|
"blockers": 25,
|
||||||
|
"with_dates": 55,
|
||||||
|
"monthly_forecast": { "2026-06": 20, "2026-07": 35 },
|
||||||
|
"projected_clear_date": "2026-08"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Components
|
||||||
|
|
||||||
|
### New Page: `VCLMultiVerticalPage.js`
|
||||||
|
|
||||||
|
Top-level page accessible from the nav drawer. Contains:
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `MultiVerticalUploadModal` | Multi-file drag-drop, filename parsing, batch preview, commit |
|
||||||
|
| `VCLMultiStatsBar` | Aggregated stats across all verticals |
|
||||||
|
| `VCLMultiVerticalTable` | Breakdown table with one row per vertical, clickable for drill-down |
|
||||||
|
| `VCLMultiTrendChart` | Monthly compliance trend with forecast line |
|
||||||
|
| `VCLMultiDonutChart` | Blocked vs In-Progress donut |
|
||||||
|
| `VerticalDetailView` | Per-metric breakdown when a vertical is selected |
|
||||||
|
| `MetricDeviceList` | Device list when a metric is selected within a vertical |
|
||||||
|
| `VerticalBurndownChart` | Per-vertical burndown projection |
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
- New entry in `NavDrawer.js`: "VCL Multi-Vertical" (or "CCP Metrics")
|
||||||
|
- Separate from existing "Compliance" and "VCL Report" entries
|
||||||
|
- Icon: `BarChart3` or `Building2` from lucide-react
|
||||||
|
|
||||||
|
### Drill-down UX Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
VCL Multi-Vertical Overview
|
||||||
|
├── Stats Bar (aggregated)
|
||||||
|
├── Trend Chart (aggregated)
|
||||||
|
├── Donut Chart (aggregated)
|
||||||
|
└── Vertical Breakdown Table
|
||||||
|
├── NTS_AEO (90%) → click
|
||||||
|
│ ├── Metric Breakdown
|
||||||
|
│ │ ├── 5.2.4 — Access & MFA (98.1%) → click
|
||||||
|
│ │ │ └── Device List (15 devices)
|
||||||
|
│ │ ├── 1.1.1 — Logging & Monitoring (85%) → click
|
||||||
|
│ │ │ └── Device List (120 devices)
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── Burndown Chart (vertical-specific)
|
||||||
|
├── SDIT_CISO (92%) → click
|
||||||
|
│ └── ...
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scoped Resolution Logic
|
||||||
|
|
||||||
|
This is the core architectural change from the existing upload flow.
|
||||||
|
|
||||||
|
### Current behavior (single-file)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Resolves ALL active items not in the upload — global scope
|
||||||
|
for (const [key, row] of Object.entries(activeMap)) {
|
||||||
|
if (!newKeys.has(key)) {
|
||||||
|
await client.query(`UPDATE compliance_items SET status = 'resolved' WHERE id = $1`, [row.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New behavior (multi-vertical)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Resolves only items within the same vertical — scoped
|
||||||
|
const { rows: activeRows } = await client.query(
|
||||||
|
`SELECT id, hostname, metric_id, seen_count FROM compliance_items
|
||||||
|
WHERE status = 'active' AND vertical = $1`,
|
||||||
|
[vertical]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [key, row] of Object.entries(activeMap)) {
|
||||||
|
if (!newKeys.has(key)) {
|
||||||
|
await client.query(`UPDATE compliance_items SET status = 'resolved', resolved_upload_id = $1 WHERE id = $2`, [uploadId, row.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The only difference is the `AND vertical = $1` filter on the active items query. This ensures uploading NTS_AEO data never touches SDIT_CISO items.
|
||||||
|
|
||||||
|
## Burndown Forecast Computation
|
||||||
|
|
||||||
|
### Per-vertical burndown
|
||||||
|
|
||||||
|
For each vertical, the burndown is computed from `resolution_date` values on active non-compliant items:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function computeVerticalBurndown(items) {
|
||||||
|
const total = items.length;
|
||||||
|
const withDates = items.filter(i => i.resolution_date != null);
|
||||||
|
const blockers = items.filter(i => i.resolution_date == null);
|
||||||
|
|
||||||
|
// Bucket by month
|
||||||
|
const monthly = {};
|
||||||
|
for (const item of withDates) {
|
||||||
|
const month = item.resolution_date.slice(0, 7); // YYYY-MM
|
||||||
|
monthly[month] = (monthly[month] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cumulative projection
|
||||||
|
let remaining = total;
|
||||||
|
const projection = {};
|
||||||
|
for (const month of Object.keys(monthly).sort()) {
|
||||||
|
remaining -= monthly[month];
|
||||||
|
projection[month] = { remediated: monthly[month], remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, blockers: blockers.length, with_dates: withDates.length, monthly, projection };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aggregated trend forecast
|
||||||
|
|
||||||
|
The trend chart forecast uses linear regression on the last 3+ monthly snapshots to project forward. This reuses the same approach as the existing VCL trend endpoint.
|
||||||
|
|
||||||
|
## Migration Script
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// backend/migrations/add_vcl_multi_vertical.js
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('Starting VCL multi-vertical migration...');
|
||||||
|
try {
|
||||||
|
// Add vertical column to compliance_items
|
||||||
|
await pool.query(`ALTER TABLE compliance_items ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||||
|
console.log('✓ vertical column added to compliance_items');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical ON compliance_items(vertical)`);
|
||||||
|
console.log('✓ idx_compliance_items_vertical index created');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_compliance_items_vertical_status ON compliance_items(vertical, status)`);
|
||||||
|
console.log('✓ idx_compliance_items_vertical_status index created');
|
||||||
|
|
||||||
|
// Add vertical column to compliance_uploads
|
||||||
|
await pool.query(`ALTER TABLE compliance_uploads ADD COLUMN IF NOT EXISTS vertical TEXT DEFAULT NULL`);
|
||||||
|
console.log('✓ vertical column added to compliance_uploads');
|
||||||
|
|
||||||
|
// Create summary table for per-vertical metric data
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS vcl_multi_vertical_summary (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
upload_id INTEGER NOT NULL REFERENCES compliance_uploads(id) ON DELETE CASCADE,
|
||||||
|
vertical TEXT NOT NULL,
|
||||||
|
metric_id TEXT NOT NULL,
|
||||||
|
metric_desc TEXT DEFAULT '',
|
||||||
|
category TEXT DEFAULT 'Other',
|
||||||
|
team TEXT DEFAULT '',
|
||||||
|
priority TEXT DEFAULT '',
|
||||||
|
non_compliant INTEGER DEFAULT 0,
|
||||||
|
compliant INTEGER DEFAULT 0,
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
compliance_pct NUMERIC(5,2) DEFAULT 0,
|
||||||
|
target NUMERIC(5,2) DEFAULT 0,
|
||||||
|
status TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('✓ vcl_multi_vertical_summary table created');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_vertical ON vcl_multi_vertical_summary(vertical)`);
|
||||||
|
console.log('✓ idx_vcl_multi_summary_vertical index created');
|
||||||
|
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_vcl_multi_summary_upload ON vcl_multi_vertical_summary(upload_id)`);
|
||||||
|
console.log('✓ idx_vcl_multi_summary_upload index created');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Migration complete.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Correctness Properties
|
||||||
|
|
||||||
|
### Property 1: Vertical-Scoped Resolution Isolation
|
||||||
|
|
||||||
|
*For any* set of active compliance items across N verticals, committing an upload for vertical X must only resolve items where `vertical = X`. The count of active items for all other verticals must remain unchanged before and after the commit.
|
||||||
|
|
||||||
|
### Property 2: Filename Parsing Completeness
|
||||||
|
|
||||||
|
*For any* filename matching the pattern `<VERTICAL>_YYYY_MM_DD.xlsx` where VERTICAL contains only alphanumeric characters and underscores, `parseVerticalFilename` must return a non-null result with the correct vertical code and ISO date string.
|
||||||
|
|
||||||
|
### Property 3: Aggregated Stats Consistency
|
||||||
|
|
||||||
|
*For any* set of per-vertical stats, the aggregated `total_devices` must equal the sum of all vertical `total_devices`, `compliant` must equal the sum of all vertical `compliant`, and `compliance_pct` must equal `Math.round((sum_compliant / sum_total) * 100)`.
|
||||||
|
|
||||||
|
### Property 4: Burndown Forecast Conservation
|
||||||
|
|
||||||
|
*For any* set of non-compliant items with resolution dates, the sum of all monthly burndown bucket counts must equal the count of items with non-null resolution dates. No item is double-counted or lost.
|
||||||
|
|
||||||
|
### Property 5: Idempotent Re-upload
|
||||||
|
|
||||||
|
*For any* vertical, uploading the same file twice on the same day must produce the same final state as uploading it once. Specifically: same active item set, same seen_counts, same resolved set.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Condition | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Filename doesn't match pattern | File flagged as "unrecognized" in preview; user can assign vertical manually |
|
||||||
|
| Duplicate vertical in batch | Reject — only one file per vertical per batch |
|
||||||
|
| Parser failure on one file | That file is marked as errored; other files in batch can still proceed |
|
||||||
|
| Transaction failure during commit | Full rollback of entire batch — no partial commits |
|
||||||
|
| File exceeds 10MB | Rejected by multer before parsing |
|
||||||
|
| No items parsed from file | Warning in preview; user can still commit (creates upload record with 0 items) |
|
||||||
|
|
||||||
|
## Deployment Considerations
|
||||||
|
|
||||||
|
- The feature is self-contained behind `/api/compliance/vcl-multi/` endpoints
|
||||||
|
- Can be deployed on a separate instance with its own database
|
||||||
|
- No changes to existing AEO compliance upload flow
|
||||||
|
- Feature flag not needed — the nav entry and endpoints simply exist or don't
|
||||||
|
- Environment variable `VCL_TARGET_PCT` (default 95) applies to multi-vertical reporting as well
|
||||||
115
.kiro/specs/vcl-multi-vertical-upload/requirements.md
Normal file
115
.kiro/specs/vcl-multi-vertical-upload/requirements.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Requirements: VCL Multi-Vertical Upload
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The compliance team generates CCP (Customer Compliance Program) metric data from CyberMetrics on a 24-hour cycle. The data is exported as separate xlsx files per organizational vertical (e.g., NTS_AEO, SDIT_CISO, TSI). They need a way to upload these files into the VCL reporting page to generate executive-level compliance reports for senior leadership across all organizations — with the ability to drill down by vertical and by metric.
|
||||||
|
|
||||||
|
This is a POC that may later be replaced by direct API integration with CyberMetrics. It will be deployed as a separate flow from the existing single-file AEO compliance upload, and may run on its own instance to isolate compliance team experimentation from dev/production data.
|
||||||
|
|
||||||
|
## Verticals (from filename convention)
|
||||||
|
|
||||||
|
| Vertical Code | Organization |
|
||||||
|
|---|---|
|
||||||
|
| AllOthers | Catch-all for unclassified |
|
||||||
|
| NTS_AEO | NTS AEO (contains sub-teams: STEAM, ACCESS-ENG, ACCESS-OPS) |
|
||||||
|
| NTS_AVVOC | NTS AVVOC |
|
||||||
|
| NTS_CPE | NTS CPE |
|
||||||
|
| NTS_NEO | NTS NEO |
|
||||||
|
| NTS_WTS | NTS WTS |
|
||||||
|
| PRDCT_VSO | Product VSO |
|
||||||
|
| SBNOE | SBNOE |
|
||||||
|
| SDIT_CISO | SDIT CISO |
|
||||||
|
| SDIT_CSD | SDIT CSD |
|
||||||
|
| SDIT_EDIS | SDIT EDIS |
|
||||||
|
| SDIT_IT | SDIT IT |
|
||||||
|
| SR | SR |
|
||||||
|
| TSI | TSI |
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
### US-1: Multi-file upload
|
||||||
|
|
||||||
|
As a compliance analyst, I want to upload multiple vertical xlsx files at once so that I can ingest a full reporting cycle without uploading one file at a time.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- 1.1: User can select or drag-drop 1–14 xlsx files simultaneously
|
||||||
|
- 1.2: System extracts the vertical code from each filename using the pattern `<VERTICAL>_YYYY_MM_DD.xlsx`
|
||||||
|
- 1.3: System extracts the report date from each filename
|
||||||
|
- 1.4: If a filename does not match the expected pattern, the user is prompted to manually assign a vertical and date
|
||||||
|
- 1.5: A preview table shows: filename, detected vertical, report date, item count, diff (new/recurring/resolved) per file
|
||||||
|
- 1.6: User can remove individual files from the batch before committing
|
||||||
|
- 1.7: User confirms the batch and all files are committed
|
||||||
|
- 1.8: Upload supports daily frequency (not just weekly)
|
||||||
|
|
||||||
|
### US-2: Vertical-scoped resolution
|
||||||
|
|
||||||
|
As a compliance analyst, I want uploading a vertical's file to only affect that vertical's data so that partial uploads don't incorrectly resolve devices from other verticals.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- 2.1: Committing a file for vertical X only resolves active items where `vertical = X`
|
||||||
|
- 2.2: Items belonging to other verticals remain unchanged
|
||||||
|
- 2.3: Re-uploading the same vertical on the same day replaces the previous state (idempotent)
|
||||||
|
- 2.4: The system tracks which upload introduced/resolved each item per vertical
|
||||||
|
|
||||||
|
### US-3: Cross-vertical VCL report
|
||||||
|
|
||||||
|
As a senior leader, I want to see an aggregated compliance report across all verticals so that I can assess organizational posture at a glance.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- 3.1: Stats bar shows aggregated totals: Total Devices, Compliant, Non-Compliant, Current %, Target %
|
||||||
|
- 3.2: Vertical breakdown table shows one row per vertical with: compliance %, non-compliant count, total devices
|
||||||
|
- 3.3: Donut chart shows Blocked vs In-Progress across all verticals
|
||||||
|
- 3.4: Trend chart shows monthly compliance % over time (aggregated)
|
||||||
|
- 3.5: Data refreshes immediately after a new upload is committed
|
||||||
|
|
||||||
|
### US-4: Vertical drill-down
|
||||||
|
|
||||||
|
As a compliance analyst, I want to click into a vertical to see its per-metric breakdown so that I can identify which metrics are driving non-compliance.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- 4.1: Clicking a vertical row navigates to a detail view for that vertical
|
||||||
|
- 4.2: Detail view shows per-metric compliance: metric ID, description, compliant count, non-compliant count, compliance %, target %
|
||||||
|
- 4.3: Metrics are grouped by category (Logging & Monitoring, Vulnerability Management, etc.)
|
||||||
|
- 4.4: Each metric row is clickable to see the device list for that metric
|
||||||
|
|
||||||
|
### US-5: Metric drill-down
|
||||||
|
|
||||||
|
As a compliance analyst, I want to click a metric within a vertical to see the non-compliant devices so that I can identify specific remediation targets.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- 5.1: Clicking a metric shows the list of non-compliant devices for that metric in that vertical
|
||||||
|
- 5.2: Device list shows: hostname, IP address, device type, team (sub-team), seen count, first seen, last seen
|
||||||
|
- 5.3: Devices can have resolution dates set (for burndown forecasting)
|
||||||
|
- 5.4: Devices can have remediation plans documented
|
||||||
|
|
||||||
|
### US-6: Burndown forecast
|
||||||
|
|
||||||
|
As a senior leader, I want to see a projected burndown timeline so that I can assess whether verticals are on track to meet compliance targets.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- 6.1: Each vertical in the breakdown table shows forecast burndown columns (monthly projections)
|
||||||
|
- 6.2: Burndown is computed from resolution_date values on non-compliant devices
|
||||||
|
- 6.3: Devices without a resolution_date count as "blockers" (no committed timeline)
|
||||||
|
- 6.4: The trend chart includes a forecast line (linear regression on 3+ months of data)
|
||||||
|
- 6.5: Per-vertical drill-down shows that vertical's burndown separately
|
||||||
|
|
||||||
|
### US-7: Separation from existing AEO upload
|
||||||
|
|
||||||
|
As a system administrator, I want the multi-vertical upload to be a separate flow from the existing AEO compliance upload so that the two don't interfere with each other.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- 7.1: Multi-vertical upload has its own UI entry point (separate from the existing "Upload" button on the AEO Compliance page)
|
||||||
|
- 7.2: Multi-vertical data is stored with a `vertical` field that distinguishes it from existing AEO-only data
|
||||||
|
- 7.3: Existing AEO compliance page continues to work unchanged
|
||||||
|
- 7.4: The VCL report page can show either multi-vertical data or fall back to existing AEO-only data if no multi-vertical uploads exist
|
||||||
|
- 7.5: The system can be deployed as a standalone instance without affecting other deployments
|
||||||
|
|
||||||
|
### US-8: Summary sheet ingestion
|
||||||
|
|
||||||
|
As a compliance analyst, I want the system to parse the Summary sheet from each vertical file so that metric-level health data (compliance %, targets) is captured per vertical.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- 8.1: Summary sheet data is stored per vertical per upload
|
||||||
|
- 8.2: Overall scores (customer_network, vertical) are captured
|
||||||
|
- 8.3: Per-metric entries include: metric_id, non_compliant, compliant, total, compliance_pct, target, status, description
|
||||||
|
- 8.4: Summary data feeds the metric drill-down view (compliance % and targets come from here)
|
||||||
169
.kiro/specs/vcl-multi-vertical-upload/tasks.md
Normal file
169
.kiro/specs/vcl-multi-vertical-upload/tasks.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Tasks: VCL Multi-Vertical Upload
|
||||||
|
|
||||||
|
## Phase 1: Database & Backend Foundation
|
||||||
|
|
||||||
|
- [x] 1. Create migration script `backend/migrations/add_vcl_multi_vertical.js`
|
||||||
|
- Add `vertical` column to `compliance_items` (TEXT, nullable, indexed)
|
||||||
|
- Add `vertical` column to `compliance_uploads` (TEXT, nullable)
|
||||||
|
- Create `vcl_multi_vertical_summary` table
|
||||||
|
- Create indexes for vertical-based queries
|
||||||
|
|
||||||
|
- [x] 2. Add `parseVerticalFilename()` helper to `backend/helpers/vclHelpers.js`
|
||||||
|
- Extract vertical code and report date from filename pattern
|
||||||
|
- Handle edge cases (no match, single-word verticals like "SR")
|
||||||
|
- Export for testing
|
||||||
|
|
||||||
|
- [x] 3. Implement vertical-scoped `persistMultiVerticalUpload()` in compliance route
|
||||||
|
- Accept items + vertical + summary + metadata
|
||||||
|
- Query active items filtered by `WHERE vertical = $1`
|
||||||
|
- Upsert new/recurring items with vertical tag
|
||||||
|
- Resolve only items within the same vertical
|
||||||
|
- Create/update compliance_snapshots for the vertical
|
||||||
|
- Store summary entries in `vcl_multi_vertical_summary`
|
||||||
|
|
||||||
|
- [x] 4. Implement `POST /api/compliance/vcl-multi/preview` endpoint
|
||||||
|
- Accept multiple files via multer `.array('files', 14)`
|
||||||
|
- Parse each file with existing Python parser
|
||||||
|
- Extract vertical from filename for each
|
||||||
|
- Compute per-vertical scoped diff
|
||||||
|
- Store parsed data in temp files
|
||||||
|
- Return batch preview response
|
||||||
|
|
||||||
|
- [x] 5. Implement `POST /api/compliance/vcl-multi/commit` endpoint
|
||||||
|
- Read temp files for each file in batch
|
||||||
|
- Commit all in a single transaction using `persistMultiVerticalUpload()`
|
||||||
|
- Rollback entire batch on any failure
|
||||||
|
- Clean up temp files
|
||||||
|
- Audit log the batch commit
|
||||||
|
|
||||||
|
## Phase 2: Reporting Endpoints
|
||||||
|
|
||||||
|
- [x] 6. Implement `GET /api/compliance/vcl-multi/stats` endpoint
|
||||||
|
- Aggregate across all verticals where `vertical IS NOT NULL`
|
||||||
|
- Compute total/compliant/non-compliant/compliance_pct
|
||||||
|
- Compute donut (blocked vs in-progress)
|
||||||
|
- Compute per-vertical breakdown with burndown
|
||||||
|
- Return structured response
|
||||||
|
|
||||||
|
- [x] 7. Implement `GET /api/compliance/vcl-multi/trend` endpoint
|
||||||
|
- Query compliance_snapshots for multi-vertical data
|
||||||
|
- Aggregate monthly compliance % across verticals
|
||||||
|
- Compute linear regression forecast (3+ months)
|
||||||
|
- Return monthly data points
|
||||||
|
|
||||||
|
- [x] 8. Implement `GET /api/compliance/vcl-multi/vertical/:code/metrics` endpoint
|
||||||
|
- Query `vcl_multi_vertical_summary` for latest upload of that vertical
|
||||||
|
- Group by category
|
||||||
|
- Return per-metric breakdown
|
||||||
|
|
||||||
|
- [x] 9. Implement `GET /api/compliance/vcl-multi/vertical/:code/metric/:metricId/devices` endpoint
|
||||||
|
- Query `compliance_items` filtered by vertical + metric_id + status = 'active'
|
||||||
|
- Include resolution_date, remediation_plan, seen_count, first/last seen
|
||||||
|
- Return device list
|
||||||
|
|
||||||
|
- [x] 10. Implement `GET /api/compliance/vcl-multi/vertical/:code/burndown` endpoint
|
||||||
|
- Query non-compliant items for vertical
|
||||||
|
- Compute monthly forecast from resolution_date values
|
||||||
|
- Return burndown data with blocker count
|
||||||
|
|
||||||
|
## Phase 3: Frontend — Upload Modal
|
||||||
|
|
||||||
|
- [x] 11. Create `MultiVerticalUploadModal.js` component
|
||||||
|
- Multi-file drag-drop zone (accept .xlsx, max 14 files)
|
||||||
|
- Filename parsing with vertical/date extraction on selection
|
||||||
|
- Display file list with detected vertical, date, status
|
||||||
|
- Allow removing individual files from batch
|
||||||
|
- Handle unrecognized filenames (manual vertical assignment)
|
||||||
|
|
||||||
|
- [x] 12. Implement preview phase in upload modal
|
||||||
|
- Call POST /preview with all files
|
||||||
|
- Display batch preview table: filename, vertical, items, diff
|
||||||
|
- Show totals row (total new, total recurring, total resolved)
|
||||||
|
- Error display for files that failed parsing
|
||||||
|
|
||||||
|
- [x] 13. Implement commit phase in upload modal
|
||||||
|
- Confirm button triggers POST /commit
|
||||||
|
- Loading state during commit
|
||||||
|
- Success state with summary of what was committed
|
||||||
|
- Error state with rollback messaging
|
||||||
|
|
||||||
|
## Phase 4: Frontend — Report Page
|
||||||
|
|
||||||
|
- [x] 14. Create `VCLMultiVerticalPage.js` page component (named CCPMetricsPage.js)
|
||||||
|
- Add to NavDrawer with appropriate icon
|
||||||
|
- Page layout: stats bar, charts row, vertical table
|
||||||
|
- Fetch data from /vcl-multi/stats on mount
|
||||||
|
- Loading and empty states
|
||||||
|
|
||||||
|
- [x] 15. Implement `VCLMultiStatsBar` component
|
||||||
|
- Total Devices, Compliant, Non-Compliant, Current %, Target %
|
||||||
|
- Match existing VCL stats bar styling
|
||||||
|
|
||||||
|
- [x] 16. Implement `VCLMultiVerticalTable` component
|
||||||
|
- One row per vertical: name, compliance %, non-compliant, total, last upload date
|
||||||
|
- Sortable columns
|
||||||
|
- Click row to drill down
|
||||||
|
- Burndown forecast columns (monthly)
|
||||||
|
- Blockers column
|
||||||
|
|
||||||
|
- [x] 17. Implement `VCLMultiTrendChart` component (recharts)
|
||||||
|
- Monthly bars for compliant count
|
||||||
|
- Solid line for actual compliance %
|
||||||
|
- Dashed line for forecast %
|
||||||
|
- Reference line for target %
|
||||||
|
- Match existing chart styling
|
||||||
|
|
||||||
|
- [x] 18. Implement `VCLMultiDonutChart` component (recharts)
|
||||||
|
- Blocked vs In-Progress segments
|
||||||
|
- Center label with total non-compliant
|
||||||
|
- Match existing donut styling
|
||||||
|
|
||||||
|
## Phase 5: Frontend — Drill-Down Views
|
||||||
|
|
||||||
|
- [x] 19. Implement `VerticalDetailView` component
|
||||||
|
- Triggered when a vertical row is clicked
|
||||||
|
- Fetch /vertical/:code/metrics
|
||||||
|
- Display per-metric table grouped by category
|
||||||
|
- Click metric to drill further
|
||||||
|
- Back button to return to overview
|
||||||
|
|
||||||
|
- [x] 20. Implement `MetricDeviceList` component
|
||||||
|
- Triggered when a metric row is clicked
|
||||||
|
- Fetch /vertical/:code/metric/:metricId/devices
|
||||||
|
- Display device table: hostname, IP, type, team, seen_count, dates
|
||||||
|
- Resolution date inline editing
|
||||||
|
- Back button to return to metric view
|
||||||
|
|
||||||
|
- [x] 21. Implement `VerticalBurndownChart` component
|
||||||
|
- Displayed in VerticalDetailView
|
||||||
|
- Fetch /vertical/:code/burndown
|
||||||
|
- Bar chart: monthly remediation projections
|
||||||
|
- Annotation for blockers count
|
||||||
|
- Projected clear date label
|
||||||
|
|
||||||
|
## Phase 6: Testing & Documentation
|
||||||
|
|
||||||
|
- [ ] 22. Write property-based tests for new helpers
|
||||||
|
- `parseVerticalFilename` — pattern matching correctness
|
||||||
|
- Vertical-scoped resolution isolation
|
||||||
|
- Aggregated stats consistency
|
||||||
|
- Burndown forecast conservation
|
||||||
|
|
||||||
|
- [ ] 23. Write unit tests for new endpoints
|
||||||
|
- Preview with valid/invalid files
|
||||||
|
- Commit with scoped resolution verification
|
||||||
|
- Stats aggregation with multiple verticals
|
||||||
|
- Drill-down queries
|
||||||
|
|
||||||
|
- [ ] 24. Update README.md
|
||||||
|
- Add VCL Multi-Vertical section to Features
|
||||||
|
- Add new migration to Migrations list
|
||||||
|
- Add new endpoints to API Reference
|
||||||
|
- Add new env vars if any
|
||||||
|
|
||||||
|
- [x] 25. Create meeting-ready design brief document
|
||||||
|
- Architectural choices and rationale
|
||||||
|
- Drill-down hierarchy diagram
|
||||||
|
- Burndown forecast explanation
|
||||||
|
- Open questions for stakeholders
|
||||||
|
- Timeline estimate
|
||||||
111
.kiro/steering/firewall-request-template.md
Normal file
111
.kiro/steering/firewall-request-template.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
inclusion: manual
|
||||||
|
---
|
||||||
|
|
||||||
|
# Firewall Exception Request Template
|
||||||
|
|
||||||
|
When the user needs to generate a Red/Blue Firewall Exception Request (LNE > Lab Network Request ticket), use this template structure. Adapt the content based on the specific service requiring network access.
|
||||||
|
|
||||||
|
## Template Structure
|
||||||
|
|
||||||
|
Every firewall request document must include these sections in order:
|
||||||
|
|
||||||
|
### 1. Title and Purpose
|
||||||
|
|
||||||
|
```
|
||||||
|
# <Service Name> Firewall Exception Request — <Application Name>
|
||||||
|
|
||||||
|
Reference material for the LNE > Lab Network Request ticket required by
|
||||||
|
<reason for the block / policy reference>. All targets are <source of truth
|
||||||
|
for the destination list>.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Destinations Table
|
||||||
|
|
||||||
|
| Cluster/Service | Destination IP/Host | Port(s) | Protocol | Encryption |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| *Name of the service* | *IP or hostname* | *port(s)* | TCP/UDP | *TLS, PLAINTEXT, etc.* |
|
||||||
|
|
||||||
|
Include notes about connection type: stateful TCP, client-initiated outbound, ephemeral source port, etc.
|
||||||
|
|
||||||
|
### 3. Source
|
||||||
|
|
||||||
|
- Source host: `<hostname>`
|
||||||
|
- Source IP: `<IP address>`
|
||||||
|
- Source port: `ephemeral / 1024-65535`
|
||||||
|
|
||||||
|
### 4. Requested Firewall Rules
|
||||||
|
|
||||||
|
Provide both per-IP rules and summary rules (if LNE accepts them):
|
||||||
|
|
||||||
|
```
|
||||||
|
Rule N
|
||||||
|
Source: <source IP>/32 ephemeral
|
||||||
|
Destination: <dest IP>/32 tcp/<port>
|
||||||
|
Protocol: TCP
|
||||||
|
Action: allow
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Suggested Ticket Text
|
||||||
|
|
||||||
|
Follow the LNE template fields exactly:
|
||||||
|
|
||||||
|
> **Summary:** Red/Blue FW Request: <Source App> → <Destination Service> (<brief description>)
|
||||||
|
>
|
||||||
|
> 1. **Reason:** What the application does and why it needs this access.
|
||||||
|
> 2. **Drivers:** Operational, compliance, or business justification. State explicitly: no PCI, no CPNI if applicable.
|
||||||
|
> 3. **Application purpose:** What the app does with the data it gets from the destination. Mention rate limits, compliance with API policies, etc.
|
||||||
|
> 4. **Source/destination IPs and ports:** Summary of flows (count, protocol, direction).
|
||||||
|
> 5. **Impact if denied:** What breaks, what the fallback is, and why the fallback is worse.
|
||||||
|
>
|
||||||
|
> **Environment Overview**
|
||||||
|
> 1. Data: What data flows over this connection. Explicitly state no PII/PCI/CPNI if true.
|
||||||
|
> 2. OS: Operating system on the source host.
|
||||||
|
> 3. OS compliance / risk acceptance: Fill in if applicable.
|
||||||
|
> 4. Architecture: Brief description of the application architecture.
|
||||||
|
>
|
||||||
|
> **External access:** State whether the source host is internet-exposed or internal-only.
|
||||||
|
>
|
||||||
|
> **Mitigation controls:** How access to the source host is controlled, how credentials are stored, what security measures are in place.
|
||||||
|
|
||||||
|
### 6. Evidence to Attach
|
||||||
|
|
||||||
|
Describe what test script to run and what log files to attach. The log should provide timestamped proof of the connection behavior (success or failure) from the source host.
|
||||||
|
|
||||||
|
### 7. Notes
|
||||||
|
|
||||||
|
Any additional context: auth methods, TLS settings, environment differences between UAT and production, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
- Be specific about IPs, ports, and protocols. Avoid vague descriptions.
|
||||||
|
- Explicitly state what data does NOT flow (no PII, no PCI, no CPNI) to speed risk assessment.
|
||||||
|
- Reference test evidence that proves the current behavior without speculation.
|
||||||
|
- If a test script exists, reference it and describe its output format.
|
||||||
|
- Use `/32` for single-host rules. Only use CIDR aggregates if you explain what extra IPs are included.
|
||||||
|
- State whether TLS is used. If PLAINTEXT, explain why (e.g., internal Kafka brokers with no client auth).
|
||||||
|
- Mention rate limiting and compliance with any API usage policies if applicable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Existing Firewall Request Documents
|
||||||
|
|
||||||
|
Reference these for examples of completed requests:
|
||||||
|
|
||||||
|
- `docs/kafka-firewall-request.md` — ZBL Impairment Map → Charter Kafka telemetry feeds (STAMP + DAA RPHY Prod). TCP PLAINTEXT to specific broker IPs on non-standard ports.
|
||||||
|
- The Jira request (generated in chat) — STEAM Dashboard → jira.charter.com:443. Single HTTPS flow with Basic Auth service account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Before Submitting
|
||||||
|
|
||||||
|
- [ ] All destination IPs/hostnames and ports are listed
|
||||||
|
- [ ] Source host IP is filled in (not placeholder)
|
||||||
|
- [ ] Protocol and encryption are specified for each flow
|
||||||
|
- [ ] Ticket text follows the LNE template fields
|
||||||
|
- [ ] Data classification is stated (PII/PCI/CPNI or lack thereof)
|
||||||
|
- [ ] Test evidence is attached or referenced
|
||||||
|
- [ ] Impact-if-denied section explains the operational consequence
|
||||||
|
- [ ] Mitigation controls describe how the source host is secured
|
||||||
40
.kiro/steering/gitlab-workflow.md
Normal file
40
.kiro/steering/gitlab-workflow.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# GitLab Issue Traceability
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
When a commit fixes or resolves a GitLab issue:
|
||||||
|
|
||||||
|
- Include `Closes #N` (or `Fixes #N`) on its own line at the end of the commit message body, where N is the GitLab issue number.
|
||||||
|
- If the fix addresses multiple issues, include one `Closes #N` per issue, each on its own line.
|
||||||
|
- The CI pipeline's `after_script` will auto-comment on the referenced issues with a link to the deploy pipeline.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Fix duplicate chart entries on compliance page
|
||||||
|
|
||||||
|
Aggregate /trends and /category-trend by report_date instead of per-upload row.
|
||||||
|
|
||||||
|
Closes #12
|
||||||
|
```
|
||||||
|
|
||||||
|
## Issue References in Code
|
||||||
|
|
||||||
|
When working from a GitLab issue, reference the issue number in:
|
||||||
|
- The commit message (required — `Closes #N`)
|
||||||
|
- The spec's `bugfix.md` introduction (for traceability from spec to issue)
|
||||||
|
- Test file header comments (optional — helps future developers find context)
|
||||||
|
|
||||||
|
## Closing Issues
|
||||||
|
|
||||||
|
- Prefer closing issues via commit message keywords (`Closes`, `Fixes`, `Resolves`) so GitLab auto-closes them when the pipeline succeeds on the default branch.
|
||||||
|
- If a commit was pushed without the keyword, close the issue via the API after confirming the deploy succeeded.
|
||||||
|
|
||||||
|
## Pipeline Notifications
|
||||||
|
|
||||||
|
The `deploy-staging` and `deploy-production` jobs in `.gitlab-ci.yml` have `after_script` blocks that parse `#N` references from the commit message and post a comment on each referenced issue with a link to the pipeline. This requires `GITLAB_PAT` to be set as a CI/CD variable in the project settings.
|
||||||
|
|
||||||
|
### Setup Required
|
||||||
|
|
||||||
|
1. Go to **Settings → CI/CD → Variables** in the GitLab project
|
||||||
|
2. Add variable: `GITLAB_PAT` = the project access token (already exists as `glpat-...` in `backend/.env`)
|
||||||
|
3. Mark it as **Protected** and **Masked**
|
||||||
@@ -5,31 +5,48 @@
|
|||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| Backend | Node.js 18+, Express 5 |
|
| Backend | Node.js 18+, Express 5 |
|
||||||
| Database | SQLite3 (file: `backend/cve_database.db`) |
|
| Database | PostgreSQL (via `pg` pool in `backend/db.js`) |
|
||||||
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry) |
|
| Auth | bcryptjs, cookie-based sessions (httpOnly, 24h expiry) |
|
||||||
| File uploads | Multer 2 (10MB limit) |
|
| File uploads | Multer 2 (10MB limit) |
|
||||||
| Frontend | React 19 (Create React App / react-scripts 5) |
|
| Frontend | React 19 (Create React App / react-scripts 5) |
|
||||||
|
| Frontend serving | Express serves `frontend/build/` as static files on port 3001 |
|
||||||
| UI Icons | lucide-react |
|
| UI Icons | lucide-react |
|
||||||
| Charts | recharts |
|
| Charts | recharts |
|
||||||
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
|
| Spreadsheet parsing | xlsx (frontend), pandas + openpyxl (backend Python scripts) |
|
||||||
| Markdown rendering | react-markdown |
|
| Markdown rendering | react-markdown |
|
||||||
| Diagrams | mermaid |
|
| Diagrams | mermaid |
|
||||||
|
|
||||||
|
## Architecture: Single-Port Serving
|
||||||
|
|
||||||
|
Express on port 3001 serves **both** the API and the production frontend build:
|
||||||
|
- API routes: `/api/*` — handled by Express route handlers
|
||||||
|
- Frontend: everything else — served as static files from `frontend/build/`
|
||||||
|
|
||||||
|
There is no separate frontend server in production. The React dev server (`npm start` on port 3000) is only for local development with hot-reload. In production and on the dev server, you must run `npm run build` in `frontend/` after any frontend code change, then restart the backend.
|
||||||
|
|
||||||
|
**After editing frontend source files:**
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build # Compile new bundle into frontend/build/
|
||||||
|
# Then restart backend (or it will serve the new static files on next request)
|
||||||
|
```
|
||||||
|
|
||||||
|
The CI/CD pipeline handles this automatically — `build-frontend` stage runs before deploy.
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
node setup.js # Initialize DB, tables, indexes, default admin user
|
node setup.js # Initialize DB, tables, indexes, default admin user
|
||||||
node server.js # Start backend on port 3001
|
node server.js # Start backend on port 3001 (serves API + frontend build)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install # Install dependencies
|
npm install # Install dependencies
|
||||||
npm start # Dev server on port 3000
|
npm run build # Production build → frontend/build/ (REQUIRED after code changes)
|
||||||
npm run build # Production build
|
npm start # Dev server on port 3000 (local dev only, NOT used in production)
|
||||||
npm test # Run tests (react-scripts test)
|
npm test # Run tests (react-scripts test)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -39,16 +56,9 @@ npm test # Run tests (react-scripts test)
|
|||||||
./stop-servers.sh # Stop all servers
|
./stop-servers.sh # Stop all servers
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Migrations (run from `backend/` in order)
|
### Database Migrations (run from `backend/`)
|
||||||
```bash
|
```bash
|
||||||
node migrations/add_knowledge_base_table.js
|
node migrations/run-all.js # Runs all migrations in order (idempotent)
|
||||||
node migrations/add_archer_tickets_table.js
|
|
||||||
node migrations/add_ivanti_sync_table.js
|
|
||||||
node migrations/add_ivanti_findings_tables.js
|
|
||||||
node migrations/add_ivanti_todo_queue_table.js
|
|
||||||
node migrations/add_card_workflow_type.js
|
|
||||||
node migrations/add_todo_queue_ip_address.js
|
|
||||||
node migrations/add_compliance_tables.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python Scripts (from `backend/scripts/`)
|
### Python Scripts (from `backend/scripts/`)
|
||||||
@@ -68,11 +78,11 @@ Python dependencies: `pandas>=2.0.0`, `openpyxl>=3.0.0` (install via apt or venv
|
|||||||
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials
|
- `backend/.env` — PORT, CORS_ORIGINS, SESSION_SECRET, NVD_API_KEY, Ivanti API credentials
|
||||||
- `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST
|
- `frontend/.env` — REACT_APP_API_BASE, REACT_APP_API_HOST
|
||||||
- Both `.env` files are gitignored; see `.env.example` files for templates.
|
- Both `.env` files are gitignored; see `.env.example` files for templates.
|
||||||
- React caches env vars at build/start time — restart the frontend process after changes.
|
- React env vars are baked in at **build time** — you must rebuild (`npm run build`) after changing them.
|
||||||
|
|
||||||
## Default Ports
|
## Ports
|
||||||
|
|
||||||
| Service | URL |
|
| Environment | URL | Notes |
|
||||||
|---------|-----|
|
|---|---|---|
|
||||||
| Frontend | http://localhost:3000 |
|
| Production / Dev server | http://IP:3001 | Express serves API + static frontend build |
|
||||||
| Backend API | http://localhost:3001 |
|
| Local dev (frontend only) | http://localhost:3000 | React dev server with hot-reload, proxies API to :3001 |
|
||||||
|
|||||||
83
.kiro/steering/versioning.md
Normal file
83
.kiro/steering/versioning.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Versioning & Release Management
|
||||||
|
|
||||||
|
## Version Numbering
|
||||||
|
|
||||||
|
This project uses **Semantic Versioning** (MAJOR.MINOR.PATCH) but with a practical cadence-based approach to avoid runaway patch numbers.
|
||||||
|
|
||||||
|
### When to bump what
|
||||||
|
|
||||||
|
| Change type | Bump | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Breaking change (new DB engine, incompatible API/config, data migration required) | MAJOR | 2.0.0 → 3.0.0 |
|
||||||
|
| New feature, new page, new integration, significant enhancement | MINOR | 2.1.0 → 2.2.0 |
|
||||||
|
| Bug fix, UI tweak, docs update, refactor with no user-visible change | PATCH | 2.1.0 → 2.1.1 |
|
||||||
|
|
||||||
|
### Cadence rules to keep numbers sane
|
||||||
|
|
||||||
|
- **Bundle bug fixes into the next minor release** rather than tagging a patch for every individual fix. Only cut a standalone patch release (x.y.Z) if a fix is urgent and needs to ship before the next feature is ready.
|
||||||
|
- **One minor bump per feature batch** — if a work session produces 2–3 features and 5 bug fixes, that's one minor release, not five patches and two minors.
|
||||||
|
- **Tag releases at logical milestones**, not per-commit. A good release boundary is: "a user would notice something new or different."
|
||||||
|
- **Never exceed x.y.5 in patches** before rolling into the next minor. If you're at x.y.5 and still shipping fixes, just bump to x.(y+1).0 and include the fixes there.
|
||||||
|
|
||||||
|
### Practical workflow
|
||||||
|
|
||||||
|
1. Work on features and fixes on `master` as normal — no version bump per commit.
|
||||||
|
2. When a logical batch is complete (end of a sprint, feature area done, before a deploy you want to mark), decide the version:
|
||||||
|
- Any breaking change since last tag? → MAJOR
|
||||||
|
- Any new features? → MINOR
|
||||||
|
- Only fixes? → PATCH (but prefer bundling into next MINOR)
|
||||||
|
3. Update `CHANGELOG.md` with the new version section.
|
||||||
|
4. Commit the changelog update.
|
||||||
|
5. Tag and push:
|
||||||
|
```bash
|
||||||
|
git tag -a vX.Y.Z -m "vX.Y.Z — short summary"
|
||||||
|
git push origin vX.Y.Z
|
||||||
|
git push backup vX.Y.Z
|
||||||
|
```
|
||||||
|
6. Create a GitLab Release from the tag (renders changelog on the Releases page):
|
||||||
|
```bash
|
||||||
|
# Extract the changelog section for this version from CHANGELOG.md
|
||||||
|
# Then create the release via GitLab API:
|
||||||
|
curl --silent --request POST \
|
||||||
|
--header "PRIVATE-TOKEN: $GITLAB_PAT" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "{
|
||||||
|
\"tag_name\": \"vX.Y.Z\",
|
||||||
|
\"name\": \"vX.Y.Z\",
|
||||||
|
\"description\": \"<changelog section in markdown>\"
|
||||||
|
}" \
|
||||||
|
"https://vulcan.apophisnetworking.net/api/v4/projects/jramos%2Fcve-dashboard/releases"
|
||||||
|
```
|
||||||
|
|
||||||
|
### GitLab Release creation details
|
||||||
|
|
||||||
|
- The GitLab instance is `https://vulcan.apophisnetworking.net`
|
||||||
|
- Project path: `jramos/cve-dashboard` (URL-encoded: `jramos%2Fcve-dashboard`)
|
||||||
|
- Auth: `GITLAB_PAT` from `backend/.env`
|
||||||
|
- The `description` field accepts full markdown — paste the relevant `## [vX.Y.Z]` section from `CHANGELOG.md`
|
||||||
|
- The release appears under **Deployments → Releases** in the GitLab sidebar with rendered markdown, download archives, and a badge showing the latest version
|
||||||
|
|
||||||
|
### What counts as a "breaking change"
|
||||||
|
|
||||||
|
- Database engine or schema change that requires migration with data transformation
|
||||||
|
- Removal or rename of API endpoints that external consumers depend on
|
||||||
|
- Environment variable changes that would break an existing deployment on pull
|
||||||
|
- Dropping support for a previously supported platform or runtime version
|
||||||
|
|
||||||
|
Adding new required env vars for *new* features is NOT breaking — existing features still work without them.
|
||||||
|
|
||||||
|
## Release Suggestion Prompt
|
||||||
|
|
||||||
|
After completing work (features, fixes, or both), suggest the next version number based on:
|
||||||
|
|
||||||
|
1. What the last tagged version is (`git tag -l --sort=-v:refname | head -1`)
|
||||||
|
2. What changed since that tag (`git log <last_tag>..HEAD --oneline`)
|
||||||
|
3. The cadence rules above
|
||||||
|
|
||||||
|
Format the suggestion as:
|
||||||
|
|
||||||
|
> **Suggested release:** vX.Y.Z
|
||||||
|
> **Reason:** [brief justification based on change types]
|
||||||
|
> **Changelog entries to add:** [bullet list of items to add]
|
||||||
|
|
||||||
|
Only suggest a release if there are meaningful user-visible changes. Internal refactors, test additions, and CI tweaks alone do not warrant a release.
|
||||||
Reference in New Issue
Block a user