SPRINT_3600_0001_0001 - Reachability Drift Detection Master Plan
This commit is contained in:
400
docs/ux/TRIAGE_UI_REDUCER_SPEC.md
Normal file
400
docs/ux/TRIAGE_UI_REDUCER_SPEC.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# Stella Ops Triage UI Reducer Spec (Pure State + Explicit Commands)
|
||||
|
||||
## 0. Purpose
|
||||
|
||||
Define a deterministic, testable UI state machine for the triage UI.
|
||||
- State transitions are pure functions.
|
||||
- Side effects are emitted as explicit Commands.
|
||||
- Enables UI "replay" for debugging (aligns with Stella's deterministic ethos).
|
||||
|
||||
Target stack: Angular 17 + TypeScript.
|
||||
|
||||
## 1. Core Concepts
|
||||
|
||||
- Action: user/system event (route change, button click, HTTP success).
|
||||
- State: all data required to render triage surfaces.
|
||||
- Command: side-effect request (HTTP, download, navigation).
|
||||
|
||||
Reducer signature:
|
||||
|
||||
```ts
|
||||
type ReduceResult = { state: TriageState; cmd: Command };
|
||||
function reduce(state: TriageState, action: Action): ReduceResult;
|
||||
```
|
||||
|
||||
## 2. State Model
|
||||
|
||||
```ts
|
||||
export type Lane =
|
||||
| "ACTIVE"
|
||||
| "BLOCKED"
|
||||
| "NEEDS_EXCEPTION"
|
||||
| "MUTED_REACH"
|
||||
| "MUTED_VEX"
|
||||
| "COMPENSATED";
|
||||
|
||||
export type Verdict = "SHIP" | "BLOCK" | "EXCEPTION";
|
||||
|
||||
export interface MutedCounts {
|
||||
reach: number;
|
||||
vex: number;
|
||||
compensated: number;
|
||||
}
|
||||
|
||||
export interface FindingRow {
|
||||
id: string; // caseId == findingId
|
||||
lane: Lane;
|
||||
verdict: Verdict;
|
||||
score: number;
|
||||
reachable: "YES" | "NO" | "UNKNOWN";
|
||||
vex: "affected" | "not_affected" | "under_investigation" | "unknown";
|
||||
exploit: "YES" | "NO" | "UNKNOWN";
|
||||
asset: string;
|
||||
updatedAt: string; // ISO
|
||||
}
|
||||
|
||||
export interface CaseHeader {
|
||||
id: string;
|
||||
verdict: Verdict;
|
||||
lane: Lane;
|
||||
score: number;
|
||||
policyId: string;
|
||||
policyVersion: string;
|
||||
inputsHash: string;
|
||||
why: string; // short narrative
|
||||
chips: Array<{ key: string; label: string; value: string; evidenceIds?: string[] }>;
|
||||
}
|
||||
|
||||
export type EvidenceType =
|
||||
| "SBOM_SLICE"
|
||||
| "VEX_DOC"
|
||||
| "PROVENANCE"
|
||||
| "CALLSTACK_SLICE"
|
||||
| "REACHABILITY_PROOF"
|
||||
| "REPLAY_MANIFEST"
|
||||
| "POLICY"
|
||||
| "SCAN_LOG"
|
||||
| "OTHER";
|
||||
|
||||
export interface EvidenceItem {
|
||||
id: string;
|
||||
type: EvidenceType;
|
||||
title: string;
|
||||
issuer?: string;
|
||||
signed: boolean;
|
||||
signedBy?: string;
|
||||
contentHash: string;
|
||||
createdAt: string;
|
||||
previewUrl?: string;
|
||||
rawUrl: string;
|
||||
}
|
||||
|
||||
export type DecisionKind = "MUTE_REACH" | "MUTE_VEX" | "ACK" | "EXCEPTION";
|
||||
|
||||
export interface DecisionItem {
|
||||
id: string;
|
||||
kind: DecisionKind;
|
||||
reasonCode: string;
|
||||
note?: string;
|
||||
ttl?: string;
|
||||
actor: { subject: string; display?: string };
|
||||
createdAt: string;
|
||||
revokedAt?: string;
|
||||
signatureRef?: string;
|
||||
}
|
||||
|
||||
export type SnapshotTrigger =
|
||||
| "FEED_UPDATE"
|
||||
| "VEX_UPDATE"
|
||||
| "SBOM_UPDATE"
|
||||
| "RUNTIME_TRACE"
|
||||
| "POLICY_UPDATE"
|
||||
| "DECISION"
|
||||
| "RESCAN";
|
||||
|
||||
export interface SnapshotItem {
|
||||
id: string;
|
||||
trigger: SnapshotTrigger;
|
||||
changedAt: string;
|
||||
fromInputsHash: string;
|
||||
toInputsHash: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface SmartDiff {
|
||||
fromInputsHash: string;
|
||||
toInputsHash: string;
|
||||
inputsChanged: Array<{ key: string; before?: string; after?: string; evidenceIds?: string[] }>;
|
||||
outputsChanged: Array<{ key: string; before?: string; after?: string; evidenceIds?: string[] }>;
|
||||
}
|
||||
|
||||
export interface TriageState {
|
||||
route: { page: "TABLE" | "CASE"; caseId?: string };
|
||||
filters: {
|
||||
showMuted: boolean;
|
||||
lane?: Lane;
|
||||
search?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
table: {
|
||||
loading: boolean;
|
||||
rows: FindingRow[];
|
||||
mutedCounts?: MutedCounts;
|
||||
error?: string;
|
||||
etag?: string;
|
||||
};
|
||||
|
||||
caseView: {
|
||||
loading: boolean;
|
||||
header?: CaseHeader;
|
||||
evidenceLoading: boolean;
|
||||
evidence?: EvidenceItem[];
|
||||
decisionsLoading: boolean;
|
||||
decisions?: DecisionItem[];
|
||||
snapshotsLoading: boolean;
|
||||
snapshots?: SnapshotItem[];
|
||||
diffLoading: boolean;
|
||||
activeDiff?: SmartDiff;
|
||||
error?: string;
|
||||
etag?: string;
|
||||
};
|
||||
|
||||
ui: {
|
||||
decisionDrawerOpen: boolean;
|
||||
diffPanelOpen: boolean;
|
||||
toast?: { kind: "success" | "error" | "info"; message: string };
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Commands
|
||||
|
||||
```ts
|
||||
export type Command =
|
||||
| { type: "NONE" }
|
||||
| { type: "HTTP_GET"; url: string; headers?: Record<string, string>; onSuccess: Action; onError: Action }
|
||||
| { type: "HTTP_POST"; url: string; body: unknown; headers?: Record<string, string>; onSuccess: Action; onError: Action }
|
||||
| { type: "HTTP_DELETE"; url: string; headers?: Record<string, string>; onSuccess: Action; onError: Action }
|
||||
| { type: "DOWNLOAD"; url: string }
|
||||
| { type: "NAVIGATE"; route: TriageState["route"] };
|
||||
```
|
||||
|
||||
## 4. Actions
|
||||
|
||||
```ts
|
||||
export type Action =
|
||||
// routing
|
||||
| { type: "ROUTE_TABLE" }
|
||||
| { type: "ROUTE_CASE"; caseId: string }
|
||||
|
||||
// table
|
||||
| { type: "TABLE_LOAD" }
|
||||
| { type: "TABLE_LOAD_OK"; rows: FindingRow[]; mutedCounts: MutedCounts; etag?: string }
|
||||
| { type: "TABLE_LOAD_ERR"; error: string }
|
||||
|
||||
| { type: "FILTER_SET_SEARCH"; search?: string }
|
||||
| { type: "FILTER_SET_LANE"; lane?: Lane }
|
||||
| { type: "FILTER_TOGGLE_SHOW_MUTED" }
|
||||
| { type: "FILTER_SET_PAGE"; page: number }
|
||||
| { type: "FILTER_SET_PAGE_SIZE"; pageSize: number }
|
||||
|
||||
// case header
|
||||
| { type: "CASE_LOAD"; caseId: string }
|
||||
| { type: "CASE_LOAD_OK"; header: CaseHeader; etag?: string }
|
||||
| { type: "CASE_LOAD_ERR"; error: string }
|
||||
|
||||
// evidence
|
||||
| { type: "EVIDENCE_LOAD"; caseId: string }
|
||||
| { type: "EVIDENCE_LOAD_OK"; evidence: EvidenceItem[] }
|
||||
| { type: "EVIDENCE_LOAD_ERR"; error: string }
|
||||
|
||||
// decisions
|
||||
| { type: "DECISIONS_LOAD"; caseId: string }
|
||||
| { type: "DECISIONS_LOAD_OK"; decisions: DecisionItem[] }
|
||||
| { type: "DECISIONS_LOAD_ERR"; error: string }
|
||||
|
||||
| { type: "DECISION_DRAWER_OPEN"; open: boolean }
|
||||
| { type: "DECISION_CREATE"; caseId: string; kind: DecisionKind; reasonCode: string; note?: string; ttl?: string }
|
||||
| { type: "DECISION_CREATE_OK"; decision: DecisionItem }
|
||||
| { type: "DECISION_CREATE_ERR"; error: string }
|
||||
|
||||
| { type: "DECISION_REVOKE"; caseId: string; decisionId: string }
|
||||
| { type: "DECISION_REVOKE_OK"; decisionId: string }
|
||||
| { type: "DECISION_REVOKE_ERR"; error: string }
|
||||
|
||||
// snapshots + smart diff
|
||||
| { type: "SNAPSHOTS_LOAD"; caseId: string }
|
||||
| { type: "SNAPSHOTS_LOAD_OK"; snapshots: SnapshotItem[] }
|
||||
| { type: "SNAPSHOTS_LOAD_ERR"; error: string }
|
||||
|
||||
| { type: "DIFF_OPEN"; open: boolean }
|
||||
| { type: "DIFF_LOAD"; caseId: string; fromInputsHash: string; toInputsHash: string }
|
||||
| { type: "DIFF_LOAD_OK"; diff: SmartDiff }
|
||||
| { type: "DIFF_LOAD_ERR"; error: string }
|
||||
|
||||
// export bundle
|
||||
| { type: "BUNDLE_EXPORT"; caseId: string }
|
||||
| { type: "BUNDLE_EXPORT_OK"; downloadUrl: string }
|
||||
| { type: "BUNDLE_EXPORT_ERR"; error: string };
|
||||
```
|
||||
|
||||
## 5. Reducer Invariants
|
||||
|
||||
* Pure: no I/O in reducer.
|
||||
* Any mutation of gating/visibility must originate from:
|
||||
* `CASE_LOAD_OK` (new computed risk)
|
||||
* `DECISION_CREATE_OK` / `DECISION_REVOKE_OK`
|
||||
* Evidence is loaded lazily; header is loaded first.
|
||||
* "Show muted" affects only table filtering, never deletes data.
|
||||
|
||||
## 6. Reducer Implementation (Reference)
|
||||
|
||||
```ts
|
||||
export function reduce(state: TriageState, action: Action): { state: TriageState; cmd: Command } {
|
||||
switch (action.type) {
|
||||
case "ROUTE_TABLE":
|
||||
return {
|
||||
state: { ...state, route: { page: "TABLE" } },
|
||||
cmd: { type: "NAVIGATE", route: { page: "TABLE" } }
|
||||
};
|
||||
|
||||
case "ROUTE_CASE":
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
route: { page: "CASE", caseId: action.caseId },
|
||||
caseView: { ...state.caseView, loading: true, error: undefined }
|
||||
},
|
||||
cmd: {
|
||||
type: "HTTP_GET",
|
||||
url: `/api/triage/v1/cases/${encodeURIComponent(action.caseId)}`,
|
||||
headers: state.caseView.etag ? { "If-None-Match": state.caseView.etag } : undefined,
|
||||
onSuccess: { type: "CASE_LOAD_OK", header: undefined as any },
|
||||
onError: { type: "CASE_LOAD_ERR", error: "" }
|
||||
}
|
||||
};
|
||||
|
||||
case "TABLE_LOAD":
|
||||
return {
|
||||
state: { ...state, table: { ...state.table, loading: true, error: undefined } },
|
||||
cmd: {
|
||||
type: "HTTP_GET",
|
||||
url: `/api/triage/v1/findings?showMuted=${state.filters.showMuted}&page=${state.filters.page}&pageSize=${state.filters.pageSize}`
|
||||
+ (state.filters.lane ? `&lane=${state.filters.lane}` : "")
|
||||
+ (state.filters.search ? `&search=${encodeURIComponent(state.filters.search)}` : ""),
|
||||
headers: state.table.etag ? { "If-None-Match": state.table.etag } : undefined,
|
||||
onSuccess: { type: "TABLE_LOAD_OK", rows: [], mutedCounts: { reach: 0, vex: 0, compensated: 0 } },
|
||||
onError: { type: "TABLE_LOAD_ERR", error: "" }
|
||||
}
|
||||
};
|
||||
|
||||
case "TABLE_LOAD_OK":
|
||||
return {
|
||||
state: { ...state, table: { ...state.table, loading: false, rows: action.rows, mutedCounts: action.mutedCounts, etag: action.etag } },
|
||||
cmd: { type: "NONE" }
|
||||
};
|
||||
|
||||
case "TABLE_LOAD_ERR":
|
||||
return {
|
||||
state: { ...state, table: { ...state.table, loading: false, error: action.error } },
|
||||
cmd: { type: "NONE" }
|
||||
};
|
||||
|
||||
case "CASE_LOAD_OK": {
|
||||
const header = action.header;
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
caseView: {
|
||||
...state.caseView,
|
||||
loading: false,
|
||||
header,
|
||||
etag: action.etag,
|
||||
evidenceLoading: true,
|
||||
decisionsLoading: true,
|
||||
snapshotsLoading: true
|
||||
}
|
||||
},
|
||||
cmd: {
|
||||
type: "HTTP_GET",
|
||||
url: `/api/triage/v1/cases/${encodeURIComponent(header.id)}/evidence`,
|
||||
onSuccess: { type: "EVIDENCE_LOAD_OK", evidence: [] },
|
||||
onError: { type: "EVIDENCE_LOAD_ERR", error: "" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case "EVIDENCE_LOAD_OK":
|
||||
return {
|
||||
state: { ...state, caseView: { ...state.caseView, evidenceLoading: false, evidence: action.evidence } },
|
||||
cmd: { type: "NONE" }
|
||||
};
|
||||
|
||||
case "DECISION_DRAWER_OPEN":
|
||||
return { state: { ...state, ui: { ...state.ui, decisionDrawerOpen: action.open } }, cmd: { type: "NONE" } };
|
||||
|
||||
case "DECISION_CREATE":
|
||||
return {
|
||||
state: state,
|
||||
cmd: {
|
||||
type: "HTTP_POST",
|
||||
url: `/api/triage/v1/decisions`,
|
||||
body: { caseId: action.caseId, kind: action.kind, reasonCode: action.reasonCode, note: action.note, ttl: action.ttl },
|
||||
onSuccess: { type: "DECISION_CREATE_OK", decision: undefined as any },
|
||||
onError: { type: "DECISION_CREATE_ERR", error: "" }
|
||||
}
|
||||
};
|
||||
|
||||
case "DECISION_CREATE_OK":
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
ui: { ...state.ui, decisionDrawerOpen: false, toast: { kind: "success", message: "Decision applied. Undo available in History." } }
|
||||
},
|
||||
// after decision, refresh header + snapshots (re-compute may occur server-side)
|
||||
cmd: { type: "HTTP_GET", url: `/api/triage/v1/cases/${encodeURIComponent(state.route.caseId!)}`, onSuccess: { type: "CASE_LOAD_OK", header: undefined as any }, onError: { type: "CASE_LOAD_ERR", error: "" } }
|
||||
};
|
||||
|
||||
case "BUNDLE_EXPORT":
|
||||
return {
|
||||
state,
|
||||
cmd: {
|
||||
type: "HTTP_POST",
|
||||
url: `/api/triage/v1/cases/${encodeURIComponent(action.caseId)}/export`,
|
||||
body: {},
|
||||
onSuccess: { type: "BUNDLE_EXPORT_OK", downloadUrl: "" },
|
||||
onError: { type: "BUNDLE_EXPORT_ERR", error: "" }
|
||||
}
|
||||
};
|
||||
|
||||
case "BUNDLE_EXPORT_OK":
|
||||
return {
|
||||
state: { ...state, ui: { ...state.ui, toast: { kind: "success", message: "Evidence bundle ready." } } },
|
||||
cmd: { type: "DOWNLOAD", url: action.downloadUrl }
|
||||
};
|
||||
|
||||
default:
|
||||
return { state, cmd: { type: "NONE" } };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Unit Testing Requirements
|
||||
|
||||
Minimum tests:
|
||||
|
||||
* Reducer purity: no global mutation.
|
||||
* TABLE_LOAD produces correct URL for filters.
|
||||
* ROUTE_CASE triggers case header load.
|
||||
* CASE_LOAD_OK triggers EVIDENCE load (and separately decisions/snapshots in your integration layer).
|
||||
* DECISION_CREATE_OK closes drawer and refreshes case header.
|
||||
* BUNDLE_EXPORT_OK emits DOWNLOAD.
|
||||
|
||||
Recommended: golden-state snapshots to ensure backwards compatibility when the state model evolves.
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Target Platform**: Angular v17 + TypeScript
|
||||
Reference in New Issue
Block a user