12 KiB
12 KiB
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:
type ReduceResult = { state: TriageState; cmd: Command };
function reduce(state: TriageState, action: Action): ReduceResult;
2. State Model
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
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
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)
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