# 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; onSuccess: Action; onError: Action } | { type: "HTTP_POST"; url: string; body: unknown; headers?: Record; onSuccess: Action; onError: Action } | { type: "HTTP_DELETE"; url: string; headers?: Record; 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