Skip to content

feat: triage step (PRD-0008)#267

Merged
galanko merged 11 commits into
mainfrom
feat/prd-0008-triage
Jun 10, 2026
Merged

feat: triage step (PRD-0008)#267
galanko merged 11 commits into
mainfrom
feat/prd-0008-triage

Conversation

@galanko

@galanko galanko commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

User description

Summary

Implements the Triage step (PRD-0008) end to end — a user-initiated, human-confirmed verdict (real / unexploitable / false_positive / needs_review) in front of the remediation flow, for both scanner findings and inbound vulnerability reports. Built to ADR-0051 + IMPL-0024, on the existing Issues page / side panel (no new page, no StageRail).

Docs: PRD-0008 · UX-0008 · ADR-0051 (amends ADR-0040 §9, closes ADR-0050 Q#1) · IMPL-0024 (all in the private cliff-os umbrella).

What landed (by milestone)

  • M1 — contract. Migration 024_triage_contract.sql (rebuild finding.exception_reason CHECK to add unexploitable, FK-off so child workspace rows aren't cascade-deleted; add sidebar_state.triage JSON column). ExceptionReason += unexploitable; IssueStage += triaging/triage_verdict/unexploitable (backend + api/client.ts). TriageOutput schema with a coherent verdict↔recommended_close invariant. SidebarState.triage + _map_triage (disjoint from evidence, overwrites on re-run). issue_derivation triage stages (an untriaged new finding never derives to a remediation stage — the Plan gate). Removed the enricher's new→triaged auto-advance (ADR-0040 §9 amendment) — triaged now means "verdict confirmed real."
  • M2 — scanner triage. triage_synthesizer (pure, deterministic, $0) projecting enricher + exposure output → verdict (ADR-0051 §3 matrix + the needs_review <0.70/unknown threshold), reachability-first so a reachable finding is never false-cleared. triage_runner.run_triage reuses the agent-execution machinery (enricher→exposure→synthesis), dual-persisting the verdict (a triage_synthesizer agent_run card + sidebar.triage) and never advancing Finding.status. POST /api/findings/{id}/triage. Deterministic eval (triage_synthesizer.jsonl) with the asymmetric false-clear HARD gate.
  • M3 — report triage. report_triager PA agent — the first read-only-tools triage producer (only the read tool; no edit/bash/gh/mcp; bounded request budget; never auto-rejects). Executor read-only-tools path. Normalizer source=report branch (_resolve_source_type). Registry chip. Live eval (report_triager.jsonl, key-gated) with tool_trace + false-clear + abstention HARD gates; keyless trust-boundary tests in CI.
  • M4 — side panel. IssueSidePanel triage section: verdict banner (colour+icon+label + confidence as word + %), progressive-disclosure ProofChecks (<details>), reachability graph incl. the calm "No path found" state, report claim-vs-code side-by-side, editable drafted reply never auto-sent. Verdict-dependent footer + two-way ClosePicker (radio semantics, pre-selects recommended_close). IssueStageChip triage stages (unexploitable = shield, distinct from false_positive = report).
  • M5 — list + dashboard. IssueRow run_triage (untriaged) / review_verdict (opens panel) actions, status-gated Todo action. IssuesPage routes untriaged→triage / triaged→remediate. ImportDialog accepts report prose for source=report. Dashboard triage payoff line. Reopen-with-evidence sends triage closes back to untriaged new.
  • M6 — polish. Canonical Cyberdeck tokens (--cd-*-soft tints), aria-live chips, radio semantics, text-labelled reachability nodes, no glow//2px borders.

Deviations from the plan (called out per the build rules)

  1. sidebar_state.triage needs a migration. IMPL-0024 §3.1 claimed the sidebar triage section needed none ("per-workspace JSON context"). The live sidebar_state store is columnar (one JSON column per section, e.g. pull_request from migration 007), so a column add is required — done in 024_triage_contract.sql.
  2. Triage endpoint is finding-scoped (POST /api/findings/{id}/triage), not IMPL-0024 §3.2's /workspaces/{id}/triage. Triage must create a non-status-advancing workspace (the finding stays new until a real verdict is confirmed — the gate). A workspace-scoped path would require the workspace to pre-exist, and creating it via the standard remediation path flips the finding to in_progress, defeating the gate. context_builder.create_workspace gained an advance_status flag (default True) for this.

Test plan

  • cd backend && uv run pytest -m 'not e2e' (keyless / CI-representative): 1475 passed, 34 skipped.
  • cd backend && uv run ruff check cliff/ tests/: clean.
  • cd frontend && npx tsc --noEmit: clean; npm run build: clean.
  • cd frontend && npm test: all triage-touched files green (IssueStageChip, IssueRow, IssuesPage, IssueSidePanel). 483 passed. See "Pre-existing baseline" below for the 16 remaining failures in untouched files.

Coverage written TDD-first: the full synthesis mapping matrix + abstention threshold; migration row-preservation + no-cascade-delete + unexploitable accepted / bogus rejected + sidebar_state.triage; the derivation gate ("Plan unreachable without a real verdict"); the removed enricher auto-advance; TriageOutput round-trip + pairing; _map_triage disjoint-from-evidence + repo round-trip; the triage run path (dual-persist + idempotent re-run + no status advance); the report triager read-only tool surface + report-source tagging + a keyless TestModel agent run; panel renders all four verdicts + the "No path found" state + claim-compare + editable-not-auto-sent reply; row/chip/footer triage actions.

QA evidence

  • Report triage, end-to-end (real LLM): the live report_triager eval ran the agent over the report dataset — it read the cited code with the read tool and returned real (SQLi with PoC), false_positive (claim contradicted by a safe parser), and needs_review (cites nonexistent code). All HARD gates held (tool_trace read-only, no false-clear, abstention). passed in ~38s.
  • Scanner triage: the deterministic triage_synthesizer eval verified the full ADR-0051 §3 matrix + the asymmetric false-clear gate (a teeth test confirms the gate fails a mislabeled real finding). $0, keyless.
  • Live route smoke: the backend boots; GET /api/agents/chips returns the report_triager chip and /api/findings/{finding_id}/triage is in the live OpenAPI.

Known QA gap (transparent): interactive browser screenshots of the Issues page / side panel (each verdict) / dashboard line were not captured in this autonomous run — the QA here is automated + eval-based + boot-level. Happy to capture the before/after screenshots on request.

Self-review

  • /security-review: the skill's auto-diff ran against main (the worktree/cwd mismatch produced an empty diff), so I conducted the review manually against origin/main...HEAD. The one new attack surface is the report_triager tool boundary — correctly read-only and workspace-confined (the existing escapes_workspace guard refuses a report's cited path that escapes the workspace), never-auto-reject, bounded budget. No injection (parameterized SQL, static migration), no HTML sinks (React-escaped, no dangerouslySetInnerHTML), no traversal/exec. No high/medium findings.
  • Review follow-up: added the keyless run_report_triager TestModel test (the executor's report path had only relied on the live eval).

Pre-existing baseline (not introduced here)

origin/main ships 16 failing frontend tests from a completed Cyberdeck migration (stale tests asserting the deprecated Material-3 design — IssueDeltaChip ×8, IssuesHeader ×4, IssueFilterChip ×2, onboardingFlow ×1, IntegrationSettings ×1). Per maintainer guidance these untouched-file failures are left as the documented baseline; the 5 stale tests in triage-touched files (IssueStageChip ×3, IssueRow ×1, IssuesPage ×1) were refreshed to current Cyberdeck output as part of this PR.

AI disclosure

This change was implemented by Claude Code (Opus 4.8) under maintainer direction, TDD-first.

🤖 Generated with Claude Code


Generated description

Below is a concise technical summary of the changes proposed in this PR:
Deliver the triage step by chaining triage_runner (enricher + exposure + deterministic triage_synthesizer or the new read-only report_triager) into the agent executor, persisting every verdict into sidebar.triage, and gating finding status changes/migrations so only a human-confirmed real verdict advances remediation. Update the API surface (POST /api/findings/{id}/triage), schema/enums, and frontend (Issues list, side panel, dashboard, import flow) so the UI reflects the triage stages, closes, and payoff messaging tied to the new contract.

TopicDetails
Triage backend Orchestrate the backend triage flow by running finding_enricher + exposure_analyzer through triage_runner, persisting deterministic triage_synthesizer output plus the read-only report_triager into sidebar.triage, enforcing the new unexploitable/triage schema + migration constraints, and guarding the triage endpoint/workspace creation so the finding stays new until a human confirms real before remediation.
Modified files (26)
  • backend/cliff/agents/executor.py
  • backend/cliff/agents/registry.py
  • backend/cliff/agents/runtime/report_triager.py
  • backend/cliff/agents/runtime/triage_synthesizer.py
  • backend/cliff/agents/schemas.py
  • backend/cliff/agents/sidebar_mapper.py
  • backend/cliff/agents/triage_runner.py
  • backend/cliff/api/routes/agent_execution.py
  • backend/cliff/db/migrations/024_triage_contract.sql
  • backend/cliff/db/repo_sidebar.py
  • backend/cliff/integrations/normalizer.py
  • backend/cliff/models/__init__.py
  • backend/cliff/models/finding.py
  • backend/cliff/models/issue_derivation.py
  • backend/cliff/workspace/context_builder.py
  • backend/tests/agents/conftest.py
  • backend/tests/agents/test_report_triager.py
  • backend/tests/agents/test_triage_runner.py
  • backend/tests/db/test_repo_sidebar_triage.py
  • backend/tests/test_issue_derivation.py
  • backend/tests/test_migration_024.py
  • backend/tests/test_routes_agent_execution.py
  • backend/tests/test_routes_findings_reject.py
  • backend/tests/test_sidebar_mapper.py
  • backend/tests/test_status_advance.py
  • backend/tests/test_triage_schema.py
Latest Contributors(1)
UserCommitDate
galank@gmail.comfeat(triage): M3 repor...June 09, 2026
Other Other files
Modified files (1)
  • backend/tests/agents/test_triage_synthesizer.py
Latest Contributors(1)
UserCommitDate
galank@gmail.comfeat(triage): M2 scann...June 09, 2026
Triage evals Formalize the triage evaluation contracts by teaching the eval tooling to load triage verdicts/files, adding deterministic/report eval runners + datasets, and gating every golden case with the asymmetric false-clear/abstention/tool_trace invariants so the new synthesis and report triager stay testable.
Modified files (8)
  • backend/cliff/evals/__init__.py
  • backend/cliff/evals/cases.py
  • backend/cliff/evals/runners.py
  • backend/tests/agents/eval/README.md
  • backend/tests/agents/eval/report_triager.jsonl
  • backend/tests/agents/eval/triage_synthesizer.jsonl
  • backend/tests/agents/test_evals_report_triager.py
  • backend/tests/agents/test_evals_triage_synthesizer.py
Latest Contributors(1)
UserCommitDate
galank@gmail.comfix(triage): address P...June 09, 2026
Triage UI Surface the triage verdicts, stages, and closes throughout the UI by extending the API client/schema, rendering Run triage/Review verdict actions in IssueRow, showing the triage section (verdict banner, proof checks, reachability, claim/model evidence, drafted reply), introducing the two-way close picker, reusing triage-focused IssueStageChip visuals, and adding dashboard payoff messaging plus keyboard-friendly focus styles so the triage gate and closes read clearly everywhere.
Modified files (14)
  • backend/tests/api/openapi_snapshot.json
  • frontend/src/api/client.ts
  • frontend/src/components/ImportDialog.tsx
  • frontend/src/components/issues/IssueRow.tsx
  • frontend/src/components/issues/IssueSidePanel.tsx
  • frontend/src/components/issues/IssueStageChip.tsx
  • frontend/src/components/issues/__tests__/IssueRow.test.tsx
  • frontend/src/components/issues/__tests__/IssueSidePanel.test.tsx
  • frontend/src/components/issues/__tests__/IssueStageChip.test.tsx
  • frontend/src/pages/DashboardPage.tsx
  • frontend/src/pages/IssuesPage.tsx
  • frontend/src/pages/__tests__/IssuesPage.test.tsx
  • frontend/src/styles/cyberdeck.css
  • frontend/src/test/fixtures/finding.ts
Latest Contributors(1)
UserCommitDate
galank@gmail.comfeat(triage): M5 issue...June 09, 2026
Review this PR on Baz | Customize your next review

galanko and others added 7 commits June 9, 2026 19:06
…n (PRD-0008)

Freeze the V1↔V2 triage contract (ADR-0051 / IMPL-0024 M1):

- migration 024: rebuild finding.exception_reason CHECK to add `unexploitable`
  (table rebuild with FK off so the workspace child rows aren't cascade-deleted);
  add a nullable JSON `triage` column to sidebar_state.
- ExceptionReason += unexploitable; IssueStage += triaging, triage_verdict,
  unexploitable (backend models + frontend api/client.ts).
- TriageOutput schema (+ nested reachability/exploitability/report/checks) with a
  coherent verdict↔recommended_close invariant; report_triager registered in
  AGENT_OUTPUT_SCHEMAS.
- SidebarState.triage section + repo_sidebar persistence + _map_triage
  (disjoint from `evidence`, overwrites on re-run).
- issue_derivation: triaging / triage_verdict / unexploitable stages; an
  untriaged `new` finding never derives to a remediation stage (the Plan gate).
- Remove the enricher's new→triaged auto-advance (ADR-0040 §9 amendment); triaged
  now means "verdict confirmed real."

Deviation from IMPL-0024 §3.1: the sidebar store is columnar, so the triage
section needs a migration (it claimed none) — done in 024.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…c eval (PRD-0008)

- triage_synthesizer: pure function projecting enricher + exposure output to a
  TriageOutput verdict (ADR-0051 §3). Reachability-first so a reachable finding
  is never false-cleared even when the enricher abstained; needs_review when
  confidence < 0.70 or exploitability unknown (§9).
- triage_runner.run_triage: reuses the agent-execution machinery — scanner runs
  enricher → exposure → synthesis, dual-persisting the verdict (a
  triage_synthesizer agent_run card + sidebar.triage); report dispatches to
  report_triager (wired in M3). Never advances Finding.status.
- POST /api/findings/{id}/triage: ensures a non-status-advancing workspace
  (the Plan gate) and runs triage in the background. (Deviation from IMPL-0024
  §3.2's /workspaces/{id}/triage path — finding-scoped so it can create a
  workspace without flipping status; documented on the endpoint.)
- context_builder.create_workspace gains advance_status (default True); triage
  passes False so the finding stays `new` until a real verdict is confirmed.
- Deterministic eval (ADR-0050 Lane 1, $0, keyless): triage_synthesizer.jsonl +
  run_triage_synthesis_eval with the asymmetric false-clear HARD gate; a teeth
  test proves the gate fails a mislabeled real finding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nch, live eval (PRD-0008)

- report_triager: the first read-only-tools triage producer (ADR-0051 §4/§8). A
  Pydantic AI agent with ONLY the `read` tool — no edit/bash/gh(push)/mcp, so it
  can never mutate the repo, close, or reply. Bounded request budget so a loopy
  model can't run away (a breach → failed run → Retry, never a clear). System
  prompt enforces: recommend + draft a reply (human sends), and needs_review
  when the cited code can't be located (never a confident false_positive).
- Executor: a read-only-tools path runs report_triager and persists via the
  shared _finalize_run (map_and_upsert → _map_triage → sidebar.triage).
- Normalizer: source=report is force-tagged source_type='report' so triage
  routes it to the report triager (pure _resolve_source_type helper).
- Registry: report_triager chip.
- Eval: report_triager.jsonl (live, key-gated) + run_report_triager_eval with
  the tool_trace (read-only), false-clear, and abstention HARD gates; keyless
  tool-surface + source-tagging tests guard the trust boundary in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- IssueStageChip: triaging (cyan pulse), triage_verdict ("Review verdict",
  awaiting tone), unexploitable (green, shield icon — distinct from
  false_positive's report icon so verdict = colour + icon + label). Fixed 3
  stale Cyberdeck-migration tests in this file (cd-loader, 9.5px sm).
- IssueSidePanel: a `triage` section (SPTriage) — verdict banner (colour +
  icon + label + confidence as word + %), progressive-disclosure ProofChecks
  (<details>), reachability graph incl. the calm "No path found" state,
  report claim-vs-code side-by-side, and an editable drafted reply that is
  never auto-sent. Verdict-dependent footer (real → Open workspace to
  remediate; needs_review → remediate/close; close → two-way ClosePicker with
  radio semantics, pre-selecting recommended_close). Triage closes reopen to
  `new` (untriaged). New sections use cd-* tokens (per the panel gotcha).
- api/client.ts already carries the contract types (M1).
- 8 new panel tests: all four verdict banners, the no-path state, the real
  footer, the two-way picker, the unexploitable close, report claim-compare +
  editable-not-auto-sent reply, and Reopen on a triage close.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- IssueRow: run_triage action (untriaged Todo, outline) + review_verdict
  (triage_verdict → opens the panel); the Todo action is gated by status —
  `new` shows Run triage, `triaged` shows Start (the Plan gate at row level).
- IssuesPage: handleActivate branches untriaged (Todo + `new`) → run triage
  (POST /findings/{id}/triage, no push guard), else → remediation Start. The
  panel gets onRunTriage (untriaged) + onStart (remediate); the panel's todo
  footer is now status-aware too.
- api/client: runTriage(findingId).
- ImportDialog: source=report accepts free-text report prose (wrapped as a
  single ingest item) for the report-triage path.
- DashboardPage: a triage payoff line ("Cliff cleared N without a fix — …·M
  real"), reading the same findings query the Issues page uses.
- Test fixture: status option + triage stages in the stage→section map.
- Fixed the stale Cyberdeck-migration tests in the triage-touched files
  (IssueRow focus-ring, IssuesPage severity dropdown) and updated the
  Start→Run-triage assertions for the new gate. Remaining 16 frontend failures
  are the pre-existing baseline in untouched files (IssueDeltaChip, IssuesHeader,
  IssueFilterChip, onboardingFlow, IntegrationSettings) — documented in the PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tokens (PRD-0008)

Use the canonical --cd-{green,red,amber}-soft tint tokens (and the bare
--cd-{green,red,amber} accents) for the verdict banner + proof-check rows
instead of invented --cd-*-dim fallbacks. Confirms the M6 conformance sweep:
verdict = colour + icon + label, aria-live stage chips, radio semantics on the
close picker, text-labelled reachability nodes, sentence-case labels, no glow /
▸ / 2px arbitrary borders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ew follow-up)

Self-review found the executor's report_triager dispatch relied only on the
live eval (real LLM). Add a keyless TestModel test of run_report_triager so the
read-only agent's run + tool registration + TriageOutput validation are
covered in CI without a key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds triage contracts, a read-only report triager agent, a deterministic scanner triage synthesizer, persistence and migration for sidebar.triage, a POST /findings/{id}/triage API, eval runners/datasets, and frontend UI/actions to run and review triage verdicts.

Changes

Triage Verdict Synthesis and User Confirmation Flow

Layer / File(s) Summary
Triage contracts and shared models
backend/cliff/agents/schemas.py, backend/cliff/models/__init__.py, frontend/src/api/client.ts, backend/tests/test_triage_schema.py
Adds TriageOutput schema, literal verdict/close types, cross-field validator for recommended_close, exposes triage on sidebar models and frontend types, and tests schema behavior.
Report triager agent runtime
backend/cliff/agents/runtime/report_triager.py, backend/cliff/agents/registry.py
New read-only PydanticAI agent with SYSTEM_PROMPT, tool surface (read,), request limit, build/run helpers, and registry entry.
Executor routing for report triager
backend/cliff/agents/executor.py
Imports and routes report_triager through a dedicated PA execution path and adds _run_pa_report_triager. Also removes finding_enricher auto-advance from _AGENT_STATUS_ADVANCE.
Deterministic scanner triage synthesizer
backend/cliff/agents/runtime/triage_synthesizer.py
Implements synthesize_triage with confidence anchors, reachability normalization, abstention detection, and explainability checks; exports threshold constant.
Triage orchestration and persistence
backend/cliff/agents/triage_runner.py, backend/cliff/agents/sidebar_mapper.py
run_triage dispatches scanner vs report flows, synthesizes/persists scanner verdicts (agent run + sidebar.triage), reads persisted report triage, and maps report_triager/triage_synthesizer outputs to sidebar.triage with replace-on-write semantics.
Database migration and sidebar persistence
backend/cliff/db/migrations/024_triage_contract.sql, backend/cliff/db/repo_sidebar.py
Migration adds unexploitable to finding.exception_reason CHECK and adds nullable sidebar_state.triage column; repo upsert/read updated to include triage.
Issue derivation and finding model updates
backend/cliff/models/finding.py, backend/cliff/models/issue_derivation.py
Adds unexploitable, triaging, triage_verdict stages, derives triage-aware stages for new findings based on agent run state and sidebar.triage.
Workspace creation control
backend/cliff/workspace/context_builder.py
Adds advance_status: bool to create_workspace and gates mark_started_on_workspace_create so triage workspaces can be created without advancing finding status.
API route to run triage
backend/cliff/api/routes/agent_execution.py
Adds POST /findings/{finding_id}/triage returning 202 and workspace_id, reuses or creates per-finding workspace with advance_status=False, and launches run_triage in background.
Eval runners, cases, and datasets
backend/cliff/evals/runners.py, backend/cliff/evals/cases.py, backend/cliff/evals/__init__.py, backend/tests/agents/eval/*
Adds deterministic run_triage_synthesis_eval, async run_report_triager_eval, extends EvalCase/Expected, adds CI/live datasets and eval tests for both lanes.
Agent runtime tests and triage runner tests
backend/tests/agents/*, backend/tests/agents/test_triage_runner.py
Adds unit and integration tests validating tool-safety, workspace-driven report triage, synthesizer mapping matrix, runner persistence, reruns, failure handling, and migration tests.
Frontend API client and import handling
frontend/src/api/client.ts, frontend/src/components/ImportDialog.tsx
Adds api.runTriage(findingId) client method and report import mode wrapping pasted report JSON into { report }.
Frontend triage UI and actions
frontend/src/components/issues/IssueSidePanel.tsx, frontend/src/components/issues/IssueRow.tsx, frontend/src/pages/IssuesPage.tsx, frontend/src/components/issues/IssueStageChip.tsx, tests
Adds triage section UI (verdict/proof/reachability/claim-vs-code/drafted reply), footer flows including TriageClosePicker, Run triage entry action for untriaged items, stage visuals, optimistic triage state updates, dashboard payoff line, and many UI tests.
Frontend tests & fixtures
frontend/src/components/issues/__tests__/*, frontend/src/test/fixtures/finding.ts
Updates fixtures to support status and adds tests for Run triage / Review verdict / triage verdict display and interactions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • cliff-security/cliff#221: Related PA no-tools execution path and executor wiring that the report_triager runtime builds upon.
  • cliff-security/cliff#251: Executor PA plumbing changes that this PR extends with report_triager routing.
  • cliff-security/cliff#265: Prior eval framework changes; this PR adds new eval runners and datasets consuming that infrastructure.

Poem

🐰 I read the code and checked the claim,

Drafted replies and verdicts came,
Synthesizer peeked where scanners trod,
Sidebar stores the careful nod,
Hop, triage done — the review is calm.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/prd-0008-triage

Comment thread frontend/src/components/issues/IssueSidePanel.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/cliff/agents/sidebar_mapper.py (1)

55-63: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make triage writes replace, not deep-merge, to avoid stale verdict payloads.

map_and_upsert deep-merges dicts for every sidebar field, so a re-triage keeps old keys that are missing in the new output. That breaks the idempotent overwrite behavior documented for triage and can persist stale contract data.

Proposed fix
@@
     for field_name in SidebarStateUpdate.model_fields:
         new_val = getattr(partial, field_name)
         if new_val is not None:
             # For dict fields, deep-merge existing and new
             existing_val = getattr(existing, field_name, None) if existing else None
-            if isinstance(existing_val, dict) and isinstance(new_val, dict):
+            if (
+                field_name != "triage"
+                and isinstance(existing_val, dict)
+                and isinstance(new_val, dict)
+            ):
                 merged = {**existing_val, **new_val}
                 merged_data[field_name] = merged
             else:
                 merged_data[field_name] = new_val

Also applies to: 175-183

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/agents/sidebar_mapper.py` around lines 55 - 63, The current
logic in map_and_upsert (the loop using SidebarStateUpdate.model_fields that
builds merged_data from partial and existing) deep-merges dict fields which
preserves stale keys; change this to always replace dict values from partial
(i.e., if new_val is not None assign new_val directly to
merged_data[field_name]) instead of merging with existing_val so triage writes
are idempotent; apply the same replacement change to the second identical block
around the other occurrence referenced (lines ~175-183) to ensure both update
paths use replace-not-merge behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/cliff/agents/runtime/triage_synthesizer.py`:
- Around line 134-136: The field verdict is declared too loosely as str (and a
type: ignore[arg-type] was added later) — tighten it to a Literal union of the
exact allowed string values so static typing and model validation are enforced;
import typing.Literal and replace verdict: str with verdict: Literal[...allowed
values...], ensure all places that assign to verdict (and any tests) use one of
those literal values, then remove the type: ignore[arg-type] suppression; also
check nearby fields exploitability and confidence for consistent typing and
update any other occurrences referenced in this file that rely on the loose
verdict type.

In `@backend/cliff/api/routes/agent_execution.py`:
- Around line 475-484: The reused workspace may have a missing or invalid
workspace_dir; before scheduling triage, verify workspace.workspace_dir is
present and points to an existing directory (e.g., os.path.exists or
pathlib.Path.exists). If the check fails, call
context_builder.create_workspace(db, finding, repo_url=repo_url,
advance_status=False) to create a fresh workspace and use that one instead of
the stale row; keep using list_workspaces and the same repo_url resolution logic
and ensure you log the replacement and only proceed to schedule the background
triage with a valid workspace_dir.

In `@backend/cliff/evals/cases.py`:
- Around line 50-51: Change the Expected.verdict annotation from a loose "str |
None" to a Literal union of the exact triage verdict strings used by the triage
pipeline (the Expected class's verdict field), e.g.
Literal['accept','reject','flag','needs_more_info'] (replace with your actual
labels); import Literal from typing (or typing_extensions if required) and keep
the None option if null is allowed (Literal[...] | None). This makes the
pydantic-backed Expected model validate golden verdicts strictly and fail fast
on bad JSONL rows.

In `@backend/cliff/evals/runners.py`:
- Around line 317-320: The loop that writes staged files uses the untrusted
variable rel directly, allowing absolute paths or “..” to escape the temp
workspace; update the staging logic (the loop handling case.files with variables
rel, text, tmp and the write to target.write_text) to reject or sanitize unsafe
paths: for each rel, compute target = (Path(tmp) / rel).resolve() and compare it
to the resolved workspace root (Path(tmp).resolve()), and if target is not
inside that root (i.e., target.resolve() does not start with the workspace root
+ path separator) then skip or raise; only then create parent dirs and
write_text. Ensure you handle absolute rels and “..” segments this way so files
cannot be written outside the temp workspace.

In `@backend/cliff/models/issue_derivation.py`:
- Around line 231-236: The current logic returns a failed triage verdict before
checking for in-flight triage, hiding active runs; modify the new-status branch
to prioritize running triage by either moving the
_triage_running(latest_runs_by_type) check above the _triage_failed(...) check
or by gating the failed branch with not _triage_running(latest_runs_by_type).
Update the code paths that call _triage_failed(latest_runs_by_type) and
_has_triage_verdict(sidebar) (and the out(...) return) so that active triage
detected by _triage_running(...) will return out("in_progress", "triaging")
instead of being overshadowed by a stale failure.

In `@backend/tests/test_routes_agent_execution.py`:
- Around line 902-903: Replace the flaky time-based wait with deterministic
synchronization: remove await asyncio.sleep(0.05) and instead create an
asyncio.Event (or similar synchronization primitive), arrange for mock_run to
set that event when it is awaited (e.g., via mock_run.side_effect / async
side_effect that calls event.set()), then await event.wait() before calling
mock_run.assert_awaited_once(); this ensures the test waits precisely for
mock_run to be invoked rather than relying on a fixed sleep.

In `@frontend/src/components/issues/IssueSidePanel.tsx`:
- Around line 2096-2097: The button label in the PrimaryButton JSX
(icon="check", onClick={submit}, disabled={!reason || reject.isPending}) uses
title case and an ampersand; change the text from "Confirm & move to Done" to
sentence case "Confirm and move to done" to comply with the UI sentence-case
rule for labels in components under frontend/src; update that string only so
reject.isPending still toggles to 'Closing…' and no other behavior changes.
- Around line 2069-2093: IssueSidePanel exposes TRIAGE_CLOSE_OPTIONS as a
radiogroup but lacks keyboard semantics; implement roving tabindex and arrow-key
navigation so arrow keys move selection and managed focus updates aria-checked
and calls setPicked. Add a ref array for the option buttons in the
IssueSidePanel component, set tabIndex=0 only for the currently selected option
(others -1), and handle onKeyDown (Left/Up = prev, Right/Down = next, Home =
first, End = last) to call setPicked(newValue) and focus the corresponding ref;
ensure click still works and aria-checked reflects reason.

In `@frontend/src/pages/IssuesPage.tsx`:
- Around line 340-355: The handler currently only prevents remediation for stage
=== 'todo' && status === 'new', so findings with derived.stage ===
'triage_verdict' fall through to remediateFinding and may start pipelines
prematurely; update handleActivate (the const handleActivate useCallback) to
additionally detect when finding.derived?.stage === 'triage_verdict' and avoid
calling remediateFinding — either call triageFinding or return early (depending
on desired UX) so triage_verdict rows are not sent to
remediateFinding/runAllPipeline automatically.

---

Outside diff comments:
In `@backend/cliff/agents/sidebar_mapper.py`:
- Around line 55-63: The current logic in map_and_upsert (the loop using
SidebarStateUpdate.model_fields that builds merged_data from partial and
existing) deep-merges dict fields which preserves stale keys; change this to
always replace dict values from partial (i.e., if new_val is not None assign
new_val directly to merged_data[field_name]) instead of merging with
existing_val so triage writes are idempotent; apply the same replacement change
to the second identical block around the other occurrence referenced (lines
~175-183) to ensure both update paths use replace-not-merge behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5f6484ac-0b19-46f4-828e-5a1beb88d177

📥 Commits

Reviewing files that changed from the base of the PR and between 7e0b8c3 and 3f5fa44.

📒 Files selected for processing (48)
  • backend/cliff/agents/executor.py
  • backend/cliff/agents/registry.py
  • backend/cliff/agents/runtime/report_triager.py
  • backend/cliff/agents/runtime/triage_synthesizer.py
  • backend/cliff/agents/schemas.py
  • backend/cliff/agents/sidebar_mapper.py
  • backend/cliff/agents/triage_runner.py
  • backend/cliff/api/routes/agent_execution.py
  • backend/cliff/db/migrations/024_triage_contract.sql
  • backend/cliff/db/repo_sidebar.py
  • backend/cliff/evals/__init__.py
  • backend/cliff/evals/cases.py
  • backend/cliff/evals/runners.py
  • backend/cliff/integrations/normalizer.py
  • backend/cliff/models/__init__.py
  • backend/cliff/models/finding.py
  • backend/cliff/models/issue_derivation.py
  • backend/cliff/workspace/context_builder.py
  • backend/tests/agents/conftest.py
  • backend/tests/agents/eval/README.md
  • backend/tests/agents/eval/report_triager.jsonl
  • backend/tests/agents/eval/triage_synthesizer.jsonl
  • backend/tests/agents/test_evals_report_triager.py
  • backend/tests/agents/test_evals_triage_synthesizer.py
  • backend/tests/agents/test_report_triager.py
  • backend/tests/agents/test_triage_runner.py
  • backend/tests/agents/test_triage_synthesizer.py
  • backend/tests/api/openapi_snapshot.json
  • backend/tests/db/test_repo_sidebar_triage.py
  • backend/tests/test_issue_derivation.py
  • backend/tests/test_migration_024.py
  • backend/tests/test_routes_agent_execution.py
  • backend/tests/test_routes_findings_reject.py
  • backend/tests/test_sidebar_mapper.py
  • backend/tests/test_status_advance.py
  • backend/tests/test_triage_schema.py
  • frontend/src/api/client.ts
  • frontend/src/components/ImportDialog.tsx
  • frontend/src/components/issues/IssueRow.tsx
  • frontend/src/components/issues/IssueSidePanel.tsx
  • frontend/src/components/issues/IssueStageChip.tsx
  • frontend/src/components/issues/__tests__/IssueRow.test.tsx
  • frontend/src/components/issues/__tests__/IssueSidePanel.test.tsx
  • frontend/src/components/issues/__tests__/IssueStageChip.test.tsx
  • frontend/src/pages/DashboardPage.tsx
  • frontend/src/pages/IssuesPage.tsx
  • frontend/src/pages/__tests__/IssuesPage.test.tsx
  • frontend/src/test/fixtures/finding.ts

Comment thread backend/cliff/agents/runtime/triage_synthesizer.py Outdated
Comment thread backend/cliff/api/routes/agent_execution.py
Comment thread backend/cliff/evals/cases.py Outdated
Comment thread backend/cliff/evals/runners.py
Comment thread backend/cliff/models/issue_derivation.py Outdated
Comment thread backend/tests/test_routes_agent_execution.py Outdated
Comment thread frontend/src/components/issues/IssueSidePanel.tsx
Comment thread frontend/src/components/issues/IssueSidePanel.tsx Outdated
Comment thread frontend/src/pages/IssuesPage.tsx
…, a11y, dedup

Backend:
- triage_synthesizer: type `verdict` as TriageVerdict + drop the forbidden
  `# type: ignore[arg-type]` (the literal now type-checks).
- evals/cases: constrain `Expected.verdict` to the verdict literal so a
  malformed golden row fails in load_cases (noqa TC001 — Pydantic needs the
  import at runtime, the finding.py convention).
- POST /findings/{id}/triage: guard a reused workspace's missing
  workspace_dir (400 now instead of a background failure).
- evals/runners: confine staged `case.files` to the temp workspace
  (reject absolute / `..` paths).
- issue_derivation: an in-flight re-triage now beats a stale failure in the
  `new` branch (running checked before failed) + a regression test.
- test: replace the fixed sleep in the triage endpoint test with a
  deterministic poll.

Frontend:
- IssuesPage.handleActivate: a `triage_verdict` finding opens the panel for
  review instead of falling through to remediation (defense-in-depth gate).
- IssueSidePanel: full WAI-ARIA radio semantics on the close picker (roving
  tabindex + arrow-key nav + focus follows); sentence-case "Confirm & close";
  extract the shared reject-with-reason mutation into useRejectWithReason so
  RejectFooter + TriageClosePicker share one implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@galanko

galanko commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — all 10 findings (9 CodeRabbit + 1 Baz) addressed in 63855a6. Mapping:

Backend

  • type: ignore in triage_synthesizer → removed; verdict is now typed TriageVerdict (the literal type-checks, so the suppression — which the build rules forbid anyway — is gone).
  • Expected.verdict unconstrained → now TriageVerdict | None, so a malformed golden JSONL row fails in load_cases (kept as a runtime import with a noqa: TC001 + rationale — Pydantic resolves the field annotation at model-build time; same convention as finding.py).
  • Reused workspace missing workspace_dir → the triage endpoint now raises 400 instead of returning 202 + failing in the background.
  • Path traversal staging case.files → the eval runner resolves each target and rejects absolute / .. paths that escape the temp workspace.
  • new-branch ordering (running vs failed) → in-flight re-triage now wins (running checked before failed), with a regression test (test_inflight_retriage_beats_a_stale_failure).
  • Fixed sleep in the endpoint test → replaced with a deterministic poll on mock_run.await_count.

Frontend

  • triage_verdict could fall through to remediationhandleActivate now opens the panel for review on triage_verdict (defense-in-depth; the row's Review-verdict button already routed via onInspect).
  • Custom radiogroup keyboard semantics → full WAI-ARIA radio pattern on the close picker: roving tabindex, Arrow Left/Right/Up/Down move + select, focus follows.
  • Confirm button sentence caseConfirm & close (consistent with the existing Approve & generate fix / Accept & close; & is the shipped convention).
  • TriageClosePicker / RejectFooter dedup (Baz) → extracted the shared reject-with-reason mutation into a useRejectWithReason hook; only the UI (chip list vs two-way radiogroup) differs now.

Gates after the fixes: backend pytest -m 'not e2e' 1476 passed / ruff clean; frontend tsc + affected tests green.

…utside-diff)

CodeRabbit flagged the map_and_upsert merge in the context of triage: a re-run
should fully replace the single-producer `triage` section, not shallow-merge,
so a stale key from a prior run (e.g. a `report` block from an earlier report
triage) can't survive a later scanner re-triage. Add a `_REPLACE_SECTIONS` set
({"triage"}) — multi-producer sections (evidence) still deep-merge. Covered by
a mapper test asserting wholesale replacement + sibling preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…audit)

QA screenshot pass + a Cyberdeck/UX-0008 conformance audit of the live triage
UI surfaced two real gaps:

- The report verdict's `ai_slop_signals` (and the duplicate / PoC-present
  flags) were never rendered — SPTriage showed claim-vs-code + the drafted
  reply but dropped the slop signals UX-0008 Story 5 requires. Add a
  ReportSignals block (amber-flagged signal list + PoC/duplicate line),
  verified live + covered by the report panel test.
- `.cd-chip` had no `:focus-visible` style, so the two-way close picker's
  radiogroup fell back to the browser default outline instead of the
  Cyberdeck green ring. Add `.cd-chip:focus-visible` (matches `.cd-btn`).

(The local QA staging helper is not committed.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@galanko

galanko commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Manual QA + UI audit (closes DoD #4)

Booted the full stack (backend :8000 + Vite :5173) against a seeded demo DB covering every triage state, and walked the UI in Chrome. All states render correctly in Cyberdeck dark mode.

Verified states (screenshots reviewed):

  • Issues listNeeds you (4 verdicts, "Review verdict" action), In progress (triaging, cyan pulse), Todo, Done. The Plan gate is visible at row level: an untriaged (new) finding shows Run triage; a confirmed-real (triaged) finding shows Start.
  • Side panel, all four verdicts — each is colour + icon + label + confidence word + %: real (red / warning / High · 92%), unexploitable (green / shield / High · 88%), false_positive (green / report / Medium · 81%), needs_review (amber / help / Low · 55%). unexploitable and false_positive carry distinct icons (shield vs report).
  • Reachability — the node-chain graph (upload endpoint → parse → sink) for real, and the calm "No path found" frame for unexploitable.
  • Progressive-disclosure proof<details> "Cliff checked N things", collapsed by default.
  • Two-way close picker — radiogroup with unexploitable pre-selected (the recommendation) vs false positive, → Confirm & close.
  • Report variant — claim-vs-code side-by-side, AI-slop signals, PoC/duplicate flags, and an editable drafted reply labelled "you edit and send this — Cliff never sends it for you."
  • Footers per verdict — real → Open workspace to remediate; needs_review → Looks real — remediate / Close; close verdicts → Accept & close.
  • Done — distinct "Not exploitable" vs "False positive" chips, evidence retained + reopenable.
  • Dashboard — the payoff line renders: "Cliff cleared 2 issues without a fix — 1 not exploitable · 1 false positive · 1 real."

Audit findings (both fixed in 8ea4168):

  1. report.ai_slop_signals was never rendered — SPTriage showed claim-vs-code + the drafted reply but dropped the slop-signals array UX-0008 Story 5 requires. Added a ReportSignals block (amber-flagged signal list + PoC/duplicate line); verified live and covered by the panel test.
  2. .cd-chip had no :focus-visible — the close-picker radiogroup fell back to the browser's default outline instead of the Cyberdeck green ring. Added .cd-chip:focus-visible matching .cd-btn.

Gates after the fixes: frontend tsc + vite build clean, panel tests green (the 16 untouched-file baseline failures are unchanged). Backend unaffected (1476 passing).

Note: the screenshots were captured + reviewed interactively; the capture tool surfaces them inline rather than to a committable path, so they aren't attached as files here.

Two small craft fixes from a critique + audit pass over the triage UI:

- SPTriage empty state now teaches the next action ("Run triage to see
  Cliff's read on this finding.") instead of the bare "No triage verdict
  yet." — empty states should point at the action, and the footer's
  "Run triage" button backs it up.
- Add a Cyberdeck green :focus-visible ring to native <summary> elements
  so keyboard focus on the triage proof / reachability / claim-vs-code
  disclosures reads consistently with .cd-btn / .cd-chip instead of the
  browser default ("practice what you preach" accessibility).

No behavior change; triage component + cyberdeck CSS tests stay green
(the 16 pre-existing Cyberdeck-migration failures in onboarding/settings/
header tests are unrelated and out of scope, confirmed unchanged by these
edits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@galanko galanko merged commit 6d0cffb into main Jun 10, 2026
8 checks passed
@galanko galanko deleted the feat/prd-0008-triage branch June 10, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant