Files
git.stella-ops.org/docs/ux/TRIAGE_UI_REDUCER_SPEC.md

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