Skip to content

feat(reviews): surface detector rationale + threat categories on hold detail#390

Open
jiashuoz wants to merge 2 commits into
feat/review-reason-uifrom
feat/review-reason-detail
Open

feat(reviews): surface detector rationale + threat categories on hold detail#390
jiashuoz wants to merge 2 commits into
feat/review-reason-uifrom
feat/review-reason-detail

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Jul 8, 2026

Copy link
Copy Markdown
Member

Stacked on #389 — base is feat/review-reason-ui, not main. Review/merge #389 first; this PR's diff is only the rationale layer.

What

Where #389's coarse review_reason says which producer held the message ("Content flagged by screening scan"), this adds the specific why to the expanded review row:

Prompt injection — instructs the agent to wire funds (0.92)

Why it's cheap

The data already exists. Every scan hold writes a protection_events row carrying the aggregate threat categories and — in the per-detector raw breakdown — the detector's one-line rationale (provider.native_verdict). ListProtectionEventsByMessage already existed in the store; it was just wired to no HTTP handler. No new detection, no migration.

Changes

Backend

  • New httpapi dep ListProtectionEventsByMessage, wired from the store.
  • review_protection.go: ProtectionFindingView + ThreatCategoryView and a builder — categories straight off the row, and a curated summary pulled from the per-detector raw (rationaleFromRaw prefers the flagged detector). The raw provider payload is never exposed to the client.
  • handleGetReview attaches protection best-effort (a failed fetch just omits it → coded review_reason stays the fallback). Populated only on the review-detail path — MessageView carries the field but the agent /messages constructor never sets it (same review-only pattern as review_reason).
  • Regenerated api/openapi.yaml + TS/Python SDK bases; spec-drift gate passes.

Frontend

  • Carry protection through the wire type → projectPending → detail.
  • reviewReason.ts: categoryLabel (open-set, prototype-key-safe) + protectionHeadline (highest-confidence scan category + rationale + score).
  • PendingRow: a "why held" callout at the top of the expanded panel; gate-only holds (no scan detail) fall back to the coarse collapsed-row label.

Tests

  • Backend builder units: category projection, flagged-detector rationale preference, gate-has-no-scan-detail, malformed JSON is safe (no panic/error).
  • Frontend: categoryLabel mapping/humanize/prototype-key safety; protectionHeadline top-category selection + gate/empty → null. (14 assertions total in reviewReason.test.ts.)
  • JSONB persistence round-trip is covered by identity's TestProtectionEvents_CreateAndList. The httpapi builder can't take a testutil DB dep (import cycle via apiserver), so coverage is split by design.

Security / privacy

  • Review surface only, account-scoped (same ownership guard as the review detail).
  • Client receives a curated summary + categories, never the raw provider blob (which can echo message content).
  • protection fields marked beta so the shape can evolve.

Verification

  • go build ./... + go vet clean; builder units + TestSpecGoldenNoDrift pass.
  • tsc --noEmit exit 0; reviewReason.test.ts 14/14.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HUhZns9F31y8J4EZpmr9NQ

jiashuoz and others added 2 commits July 7, 2026 21:14
… detail

Stacked on #389. Where the coarse review_reason answers "which producer held
this" ("Content flagged by screening scan"), this adds the specific "why" to the
expanded review row — e.g. "Prompt injection — instructs the agent to wire funds
(0.92)".

The data already existed: every scan hold writes a protection_events row with
the aggregate threat `categories` and, in the per-detector `raw` breakdown, the
detector's one-line rationale (provider.native_verdict). It was just unexposed —
ListProtectionEventsByMessage existed but was wired to no HTTP handler. No new
detection, no migration.

Backend:
- New httpapi dep ListProtectionEventsByMessage, wired from the store.
- review_protection.go: ProtectionFindingView + ThreatCategoryView, and a
  builder that projects the audit rows — categories straight off the row, and a
  CURATED summary pulled from the per-detector raw (rationaleFromRaw prefers the
  flagged detector). The raw provider payload is never exposed to the client.
- handleGetReview attaches `protection` best-effort (a failed events fetch just
  omits it → the coded review_reason remains the fallback). Populated ONLY on
  the review-detail path — MessageView carries the field but the agent /messages
  constructor never sets it (same review-only pattern as review_reason).
- Regenerated api/openapi.yaml + TS/Python SDK bases; spec-drift gate passes.

Frontend:
- Carry `protection` through the wire type + projectPending into the detail.
- reviewReason.ts: categoryLabel (open-set, prototype-safe like reviewReasonLabel)
  + protectionHeadline (highest-confidence scan category + rationale + score).
- PendingRow: a "why held" callout at the top of the expanded panel; gate-only
  holds (no scan detail) fall back to the coarse collapsed-row label.

Tests:
- Builder units: category projection, flagged-detector rationale preference,
  gate-has-no-scan-detail, malformed JSON is safe (no panic/error).
- Frontend: categoryLabel mapping/humanize/prototype-key safety, and
  protectionHeadline top-category selection + gate/empty → null.
- JSONB persistence round-trip is already covered by identity's
  TestProtectionEvents_CreateAndList; the httpapi builder can't take a testutil
  DB dep (import cycle via apiserver), so coverage is split by design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUhZns9F31y8J4EZpmr9NQ
From the review + adversarial pass on #390 (all three agents cleared the
safety claims — no-raw-leak, review-only, panic-safety — these are the polish
findings):

- ux: the expanded callout showed the finding-level scan score, which can
  contradict the category label it sits next to (and differ from the collapsed
  row's number). protectionHeadline now shows the top CATEGORY's own score,
  falling back to the finding score when the category has none.
- docs: ProtectionFindingView's comment claimed a gate finding "names the
  address" — it has no address field (deliberately; the reviewer already sees
  sender/recipient). Corrected the comment to match.
- observability: the best-effort ListProtectionEventsByMessage error was
  swallowed silently; log it so a persistently-empty `protection` is greppable.
- tests: handler-level TestReviews_DetailIncludesProtection (proves the dep
  wiring + attach end-to-end); extend the agent-surface non-leak test to assert
  `protection` is nil off the shared constructor; frontend cases for
  category-score-over-finding-score, no-category-score fallback, SWR-array
  non-mutation, and a prototype-key category name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUhZns9F31y8J4EZpmr9NQ
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