Skip to content

Commit 1a97746

Browse files
github-actions[bot]Marfuenclaude
authored
feat(risks): treatment plan as first-class + vendor AI widening + matrix polish
* feat(api): add independent-dimension schema for vendor risk assessment * feat(api): widen vendor AI risk assessment to independent likelihood + impact Previously the AI picked one of five canonical levels and we mapped it to one of five diagonal cells on the 5x5 matrix, so vendor scores pooled at 1/10 or 2/10. Now the assessment outputs both dimensions independently, threading through to vendor.inherentProbability / .inherentImpact unchanged. - Replaces the single-level/diagonal-mapping block with extractInherentRisk - Removes riskLevelSchema, normalizeRiskLevel, mapRiskLevelToLikelihood, mapRiskLevelToImpact, and the normalization trip through gpt-5.2 - Updates the upstream Firecrawl agent schema + prompt to score both dimensions independently (legacy risk_level retained as optional so pre-ENG-221 stored payloads still parse) Part of ENG-221. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): apply ENG-221 Phase 1 code-review feedback - Firecrawl agent schema now REQUIRES likelihood/impact/rationale so the LLM cannot skip them and silently default to a diagonal cell. - extractInherentRisk returning null now preserves existing vendor scores instead of overwriting them with possible x moderate. Emits a warn log. - Extract assessmentOutputSchema + extractInherentRisk to their own module (vendor-risk-assessment/assessment-output.ts) so tests no longer need to jest.mock the @db module. - TODO comments point legacy risk_level fields at the backfill follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ai): upgrade gpt-5.2 / gpt-5.1 references to gpt-5.5 GPT-5.5 shipped 2026-04-23 with a 1M-token context window and is now the recommended frontier model. Swaps the two full-model call sites: - apps/app/src/trigger/tasks/auditor/generate-auditor-content.ts - apps/api/src/policies/policies.controller.ts Left the gpt-5-mini / gpt-4o-mini / gpt-5 / gpt-4.1-mini sites alone — no gpt-5.5-mini variant exists yet, and the other older-model sites are used for deliberately cost-tuned flows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(db): add treatment strategy fields to Vendor model (ENG-221) * feat(app): add suggested-residual util with strategy + completion model (ENG-221) * feat(api): accept treatment strategy fields on Vendor update (ENG-221) * feat(app): regenerate flow writes treatment description instead of comment (ENG-221) * chore(db): regenerate api prisma client after vendor treatment fields (ENG-221) * feat(app): matrix UX polish + ghost marker for suggested residual (ENG-221) Adds axis tier tooltips, a color legend, a dashed ghost marker for the suggested residual, and an "Accept suggested residual" button to RiskMatrixChart. Wires `suggestedLikelihood` / `suggestedImpact` and `titleInfo` through the four wrappers (risk + vendor × inherent + residual) so customers can see, at a glance, what the matrix cells and axis tiers mean and how their treatment plan affects the suggested residual. Splits the matrix grid render into MatrixBody.tsx to keep files under the 300-line guideline; adds AxisTooltip.tsx, MatrixLegend.tsx, and a component spec covering legend, suggestion-differs gating, accept-snap, and ghost-marker suppression when suggestion matches active. * feat(app): treatment-plan tab scaffold with strategy, editor, linked-work, delta (ENG-221) * feat(app): add Treatment Plan tab to Risk detail page (ENG-221) Adds a new "Treatment Plan" tab between Overview and Risk Matrix on the risk detail page. Reuses the shared TreatmentPlanTab component (strategy picker, description editor, linked work, delta chip). Removes the legacy "Regenerate Risk Mitigation" block from the Settings tab since regeneration now lives inline in the treatment plan's description editor. Extends RisksService.findById to include linked tasks with status and controls so the tab can render the linked-work suggestion and residual completion preview. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app): add Treatment Plan tab to Vendor detail page (ENG-221) Adds a "Treatment Plan" tab between Overview and Risk Matrix on the vendor detail page, reusing the shared TreatmentPlanTab component. Wires update handlers to PATCH treatmentStrategy/treatmentStrategyDescription via the existing updateVendor action and reuses the existing regenerateMitigation endpoint. Removes the legacy "Regenerate Mitigation" block from the Settings tab (regeneration now lives inside the treatment plan tab); "Regenerate Assessment" remains in Settings. Extends VendorsService.findById to include linked tasks with status and controls. Extends the frontend Vendor/UpdateVendorData types with treatmentStrategy and treatmentStrategyDescription fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app): residual risk column on vendor list (ENG-221) * feat(app): residual risk column on risk list (ENG-221) * fix(app): address cubic review findings on ENG-221 Identified by cubic (cubic.dev) on PR #2671: - generate-risk-mitigation / generate-vendor-mitigation: stop short-circuiting the fanout when no owner/admin exists. Now descriptions still generate; the reassignment step is just skipped. authorId is optional on the individual-task payload. - ResidualRiskChart / VendorResidualRiskChart: only compute and pass a suggestion when tasks are actually loaded. Fallback to [] produced a misleading "0% complete" ghost cell on unhydrated views. - TreatmentPlanTab: sync local strategy state when the entity prop changes (e.g. after SWR revalidation). - DescriptionEditor: disable the textarea during save and avoid re-syncing draft from props while saving, so mid-save typing isn't overwritten. - LinkedWork: task link path /task/ -> /tasks/[taskId] to match the real Next.js route. - suggested-residual.ts: drop the stale reference to the gitignored spec path in the JSDoc; point readers at the in-file STRATEGY_REDUCTION table. Skipped (out of scope): cubic flagged rejectUnauthorized: false + the localhost regex in apps/api/prisma/client.js. That logic lives in packages/db/src/client.ts (and three other sibling prisma clients) and pre-dates ENG-221 — our commit only regenerated the compiled artifact. Fixing it is a broader security review, separate ticket. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app): redesign Treatment Plan tab per Direction B1 (ENG-221) * feat(app): add link-suggestions util with dept boost + threshold + top-K * chore(app): add @upstash/vector dep for risk/vendor linkage * feat(app): add upstash + openai embedding helpers for entity linkage * feat(app): add link-risks-and-vendors-to-work trigger task * feat(app): run risk/vendor linkage between createRisks and mitigations * feat(app): extend RISK_MITIGATION_PROMPT with linked tasks/controls grounding * feat(app): ground risk + vendor mitigation prompts in linked tasks/controls * fix(app): make hallucination-guard regex case-insensitive + match real identifiers * feat(app): add auto-link endpoints for risk + vendor * feat(app): on-demand Auto-link tasks button on Treatment Plan tab * refactor(app): extract runLinkage so on-demand routes return real link counts * feat(app): add unlink endpoints for risk + vendor tasks * feat(app): unlink × per task in Linked Work * feat(app): vendor 3-branch render based on Vendor.status Adds three-branch rendering to VendorResidualRiskChart based on Vendor.status: - not_assessed: render a NotAssessedState empty-state card with a "Run risk assessment" button that triggers the existing AI assessment endpoint - in_progress: render the matrix with a "Preliminary - assessment still running" subtitle so the suggested residual ghost cell isn't trusted as final - assessed: unchanged from today Adds a new optional `preliminary?: boolean` prop to RiskMatrixChart and RiskMatrix5x5 that controls only the subtitle render (no math change). Reuses the existing `triggerAssessment` mutation in use-vendors.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app): add credentials:include to vendor regenerate-mitigation fetch * fix(app): explicit ownership check on auto-link routes (404 on cross-org) * feat(app): auto-link via trigger.dev with realtime metadata phases * feat(app): subscribe AutoLinkButton to live trigger.dev run progress * fix(app): color hero numerals + scale markers by actual risk level * refactor(app): pin treatment-plan citations to real entities (eliminate hallucinations) The LLM no longer chooses which controls/tasks/policies to cite. The backend now selects 5 citations deterministically (controls -> tasks -> policies -> gap fillers) and the LLM only writes a 5-sentence JSON object via generateObject. Suffixes like "(Control: cc1-1 Risk Assessments)" are appended programmatically, so they cannot drift from reality. This removes the regex-based guardAgainstHallucinatedCodes retry loop, which only caught a narrow set of fabrication patterns and was fragile against title-cased / re-cased codes. * feat(app): refresh treatment plan when a task is unlinked After a successful unlink we fire-and-forget the corresponding mitigation trigger task so the persisted treatmentStrategyDescription reflects the new linkage. Failures here are swallowed because the unlink itself already succeeded and is the user-facing operation. * feat(app): re-link tasks from scratch with confirmation + realtime progress * feat(app): rename relink to Re-assess, move into Linked work header Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app): restructure Treatment Plan tab to B1-flat layout Collapse the three nested cards under the hero into a single bordered workspace divided by 1px vertical rules. Strategy options become borderless rows with a left-edge accent on the active item. Description editor textarea is flush with the column flow; footer separates via a hairline rule. Linked work groups drop their card wrappers, use a 3px progress bar, and hairline-divide rows. The Re-assess trigger becomes a subtle 11px muted-foreground inline-flex affordance that tints to primary on hover. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app): suggestionsOnly mode in runLinkage + apply endpoint The previous auto-link flow ran the AI scan and persisted task links in one step. The new flow splits these: the auto-link route triggers the linkage task in `suggestionsOnly` mode (returns SuggestedTask + SuggestedControl arrays in run.output, no DB writes), and a new `/auto-link/apply` endpoint persists only the user-confirmed selection — supporting both additive (replace=false) and re-assess sync (replace=true) semantics. Controls in the suggestions block are derived through tasks per the existing Risk↔Task↔Control ADR (no direct Risk↔Control linkage). The UI will render them as read-only and dim them when their parent task is unchecked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app): replace immediate-apply auto-link with review-before-apply UX Linked Work column now drives a 4-state machine (linked / empty / loading / suggestions) via the new <AutoLinkSuggestions> component: - empty: dashed CTA with "Suggest with AI" — kicks off the AI scan - loading: spinner + 4 staggered shimmer rows - suggestions: tinted banner + tasks (with checkboxes + confidence pills) + read-only Controls section (derived through selected tasks per ADR) + Re-run / Discard / Link N footer - linked: existing flat Tasks/Controls list with subtle "Re-assess" trigger Re-assess mode pre-checks current+AI tasks and applies with replace=true (sync semantics). Fresh suggest applies with replace=false (additive). The previous AutoLinkButton + RelinkButton (with AlertDialog confirm) are no longer wired in but kept intact for backward compat. Hooks expose new suggestRiskLinks/applyRiskLinks (and vendor variants) alongside the deprecated autoLinkRisk/relinkRisk. The Re-assess affordance lives at the top-right of the AutoLinkSuggestions linked-state body rather than the column header. Visually equivalent and keeps the column header free of state-dependent actions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app): strategy-aware treatment plan UX (drop avoid, mitigate-only linked work, merged empty state) - StrategyPicker: Mitigate first (default + workhorse). Drop Avoid from new picks; keep it visible only when an existing risk is already set to avoid (legacy state). Order: Mitigate, Accept, Transfer. - TreatmentPlanTab: hide column 03 (Linked work) for non-Mitigate strategies (only Mitigate's residual is task-driven). Adapt column 02 title/subtitle to "Rationale" for Accept/Transfer. - Mitigate fully-empty (no plan AND no linked tasks): merge columns 02+03 into a single 2-column layout with a "Mitigation plan" CTA spanning the wider space. - TreatmentHero: derive residual from the chosen strategy via previewResidual (matrix marker = full-completion target). Hero numeral uses interpolatedResidualScore so partial Mitigate completion shows live progress on the 1-10 scale even when the matrix cell would otherwise stay put due to integer step rounding. Third stat adapts per strategy ("Task Completion %", "Accepted as-is", etc.). Narrative reflects empty state ("since no mitigation plan is in place yet"). - LinkedWork: drop the unlink (×) button; tasks and controls now click through to the entity in a new tab. Incomplete tasks/controls show a red × instead of a muted dash; controls completeness derives from parent task status. - DescriptionEditor: remove the redundant "AI draft" header button — the footer "Regenerate with AI" already covers it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(app): kick-off CTA when treatment plan and links are both empty When Mitigate has no plan and no linked tasks, render a single centered 'Let AI kick this off' panel in cols 02+03 instead of the editor. The panel offers two paths: 'Draft plan & suggest links' (runs the AI flow which produces both plan and links) or 'Start from scratch' (dismisses the kickoff so the editor renders for manual entry). Below the buttons, two preview rows ('02 · Plan' / '03 · Links') describe what the AI will fill in. Once either plan or links exist, the regular layout takes over. * feat(risks): show kickoff empty state across cols 02+03 when mitigate has no linked work When Mitigate has no linked tasks, render only the kickoff CTA spanning the merged 02+03 column — the editor stays hidden until linked work exists or the user dismisses. Adds a 'kickoff-with-plan' variant that adapts copy to "your plan stays as-is unless you regenerate" so users who already have a plan still see a single, full-width entry point. The kickoff panel now uses a primary-tinted background that matches the selected-strategy chrome. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): broaden auto-link suggestions and scope to purchased frameworks Two complementary fixes for the on-demand suggestion path: 1. Scope tasks to purchased-framework coverage. A task is in scope when it has at least one non-archived control (controls archive when their framework subscription drops) or has no controls at all (custom user tasks). Stale tasks tied only to archived controls are excluded. 2. Loosen the suggestionsOnly threshold. The autonomous onboarding path keeps 0.65/topK=5 (high precision, no human review). The review- before-apply path now uses 0.40/topK=15 with a 50-candidate vector query so genuinely related work in the 0.4-0.6 cosine band — the range that dominates `text-embedding-3-small` for short compliance prose — actually surfaces. The user picks from the list, so favoring recall is the right tradeoff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(risks): add LLM reranker for auto-link suggestions Cosine similarity on text-embedding-3-small does well on recall but collapses scores into a tight 0.6 band on short compliance prose, which is bad for ordering. Empirical sample (Data Leakage via Personal Laptops risk on a real org): "Office Door Monitoring" and "Employee Performance Evaluations" scored ~0.62 alongside genuinely-relevant tasks like 2FA and Encryption at Rest, while the primary control "Secure Devices" (BitLocker, FileVault, MDM) didn't make the top 15. Bridge that gap with a precision-step reranker: - New util `rerank-suggestions.ts` calls gpt-5-mini with strict 0-10 scoring rubric: 10 = primary control, 7-9 = supporting, 4-6 = weakly related, 1-3 = tangential, 0 = irrelevant. Rubric explicitly warns against surface-keyword false positives. - `runLinkage` (suggestionsOnly path) now feeds top-30 cosine candidates through the reranker and slices to top-15 by rerank score. The autonomous onboarding path keeps its strict 0.65 cosine cutoff unchanged — no LLM cost on bulk runs. - Reranker failure (network / OpenAI outage) falls back to cosine ordering so the suggestions UI never breaks. - Score map values are scaled 0-1 from the reranker's 0-10 so the existing SuggestedTask UI continues to display them as a percentage. Cost: ~$0.001 per risk (one call, ~30 short rows). Latency: 2-5s on top of the existing scanning flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(risks): persist auto-link runs so users don't lose progress on reload The auto-link AI scan was already running on trigger.dev, but the runId lived only in component state. Closing the tab orphaned the in-flight run from the UI — when the user came back, they'd see the empty state even though the run had completed (or was still running) server-side. Persist runId on Risk + Vendor: - Schema migration adds `autoLinkRunId` and `autoLinkRunStartedAt` to Risk and Vendor. Set when /auto-link triggers a new run, cleared by /auto-link/apply (user committed) or /auto-link/active DELETE (user discarded). - New `GET /auto-link/active` mints a fresh public-access token (the previous one expires after 15 minutes) so the UI can re-subscribe via `useRealtimeRun` after a reload. Returns `{ runId: null }` when the trigger.dev run has been purged (TTL elapsed) so we don't subscribe to a dead id. - New `DELETE /auto-link/active` drops the persisted runId for Discard. Front-end state machine handles every trigger.dev run status: - AutoLinkSuggestions calls `onResume` on mount; if a run exists, jumps straight into the loading state and re-subscribes. - LoadingState now distinguishes WAITING_FOR_DEPLOY / QUEUED / DELAYED / INTERRUPTED / WAITING_TO_RESUME / EXECUTING with status-specific copy derived from `run.status` + `run.metadata.phase`. Adds a progress bar for the embedding-tasks phase (current / total). - New FailedState shows a retry-able error for terminal failures (FAILED, CANCELED, CRASHED, SYSTEM_FAILURE, EXPIRED, TIMED_OUT). Retry kicks off a fresh /auto-link call; Discard clears the runId. Also widens the 5x5 matrix (CELL_SIZE 36 -> 44) and centers it in its card so the empty space on the right of the hero shrinks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(linkage): parallelize matching, scope-skip irrelevant entities, quieter logs Several efficiency wins on the linkage trigger task plus a logging overhaul: - Drop `PrismaInstrumentation` from trigger.config — every Prisma query was emitting a `prisma:client:operation` span that drowned out our own task logs. Per-task `logger.info` calls give us the visibility we actually need. - Run the 3 initial scope queries (risks, vendors, tasks) in parallel via Promise.all instead of awaiting each sequentially. - Skip the irrelevant scope entirely: when the caller pins riskId, don't load + embed all vendors (and vice versa). Saves an OpenAI embedding round-trip per Suggest click on orgs with many vendors. - Replace-step disconnects (`tasks: { set: [] }`) now fan out via Promise.all instead of running serially. - Per-entity matching loops (vector query → rerank → DB update) use a bounded-concurrency runner (4 at a time). For onboarding with ~10-20 entities this is the biggest wall-clock win — each iteration was previously serial on Upstash + OpenAI round-trips. - Linkage task now logs phase boundaries with timings, scope sizes, rerank input/output (top-10 ids + scores), and a clear ✓/✗ summary with elapsed time. console.info inside `runLinkage` flows into the trigger.dev run log without coupling the lib to the trigger SDK. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(linkage): upgrade to text-embedding-3-large (1536-dim Matryoshka) Switch from `text-embedding-3-small` to `text-embedding-3-large` truncated to 1536 dims via the OpenAI Matryoshka `dimensions` parameter. Even truncated, -3-large outperforms -3-small on MTEB (~64.6 vs 62.2 avg). The 1536-dim cap keeps the existing Upstash Vector index (provisioned at 1536) usable without a recreate or one-time re-embed of every org. Cost goes from ~$0.0002 to ~$0.0008 per scan — negligible. The first run after deploy will overwrite each org's task vectors with the new -3-large embeddings; matching is internally consistent since both the task vectors and the query vector come from the same model on every run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app): silence prisma `export *` warning + memoize resume callback Two related Turbopack-noise fixes: - `apps/app/prisma/{index,server}.ts` were doing `export * from '@prisma/client'`. The Prisma client is CommonJS, so Turbopack emits "unexpected export *" on every compile and re-emits it on each HMR cycle. Replace the wildcard with `export type *` (works fine for the type surface) plus an explicit list of runtime values (Prisma, PrismaClient, every enum). Same public surface, clean compile. - Memoize `handleResumeAutoLink` and `handleDiscardAutoLinkRun` in RiskPageClient and VendorDetailTabs with `useCallback`. Without the memoization, the callbacks got new identities on every parent render, which re-fired the resume `useEffect` in AutoLinkSuggestions and triggered a fresh GET `/auto-link/active` per render. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): hero shows inherent → target, with partial-progress subline Previously the hero numeral was the *interpolated* score by task completion, so linking tasks (without completing them) didn't move the headline at all — yet the narrative still said "...assuming linked tasks complete on schedule". That read self-contradictory: at 0% completion the score said "no change" while the strategy clearly forecasts a reduction. Two changes: - Headline always shows inherent → full-completion target. Linking tasks to a Mitigate plan now visibly moves the right-hand numeral. - When Mitigate is partway through (0 < completion < 1), a smaller "Currently X/10 — Y% of plan complete" line renders under the headline, colored by the interpolated current level. Real-time progress still shows up — just not as the dominant figure. Test fixture updated: with Likely × Major inherent and Mitigate's -1L/-1I target, the headline now renders 7 → 4 (was 7 → 7 with 0% complete). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(risks): add "How is this calculated?" popover to the treatment hero CISOs reviewing the score want to understand how it was derived, not just see the numbers. Added a small affordance in the hero header that opens a popover with a concise explanation: - Inherent risk: standard 5x5 likelihood x impact, normalized 1-10 - Treatment target: per-strategy projection (mitigate / transfer / accept / avoid), described in plain language - Current vs. target: how completion drives the in-progress score - Footer references NIST SP 800-30 / ISO 27005 alignment Copy is deliberately CISO-credible (industry vocabulary, framework references) but does NOT publish the exact step-down coefficients — those live in `lib/suggested-residual.ts` and can evolve as we calibrate without renegotiating the user-facing explanation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): make score explainer specific (formulas) and drop Avoid Previous version was too vague — read like marketing copy, not an explanation. CISOs want the actual math. The popover now shows: - Step 1 inherent: explicit raw = L x I and score = ceil(raw / 2.5) formulas in monospace blocks - Step 2 target: per-strategy axis effects (Mitigate moves both axes, Transfer moves impact only, Accept = inherent), without publishing the exact step-down counts - Step 3 current vs. target: explicit completion formula and the linear interpolation between inherent and target by task completion - NIST SP 800-30 (semi-quantitative) + ISO 27005 (treatment) reference Avoid is gone — we dropped it from the strategy picker earlier, so it no longer needs an entry in the explainer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): blur backdrop on score explainer + honest standard references Two refinements to the "How is this calculated?" popover: - Switch from the DS Popover wrapper to base-ui's Popover directly so we can mount a `Popover.Backdrop`. Adds a subtle 2px backdrop blur over a 30% bg overlay when the popover opens — focuses attention on the explainer without making it feel modal. - Sharpen the methodology claim. The previous "aligns with NIST/ISO" was too marketing-y. New References section links to: - NIST SP 800-30 Rev. 1 (canonical csrc.nist.gov URL, verified) — noting Appendix I recognizes 5x5 matrices as semi-quantitative - ISO/IEC 27005:2022 (iso.org standard page) — naming the actual treatment categories (risk modification / sharing / retention) and mapping them to our Mitigate / Transfer / Accept Plus a closing italic disclaimer that the 1-10 normalization and step- down magnitudes are our own calibration, so we're not overclaiming alignment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(risks): markdown preview + auto-grow editor for treatment plans DescriptionEditor now defaults to a rendered markdown preview when the plan has content. Click "Edit" to flip to a textarea that auto-grows to fit the content (no fixed height + manual scrollbar) — useful for AI- generated plans that often run several paragraphs with bullets. - ReactMarkdown + remark-gfm with treatment-plan-tuned components (paragraphs, bullet/ordered lists, headings demoted by one level, bold/italic, links, code spans, blockquotes, hr). - useLayoutEffect sizes the textarea on draft change and on mode flip. - "Cancel" while editing reverts the draft and returns to preview; "Save" persists and flips back to preview. - Empty state still shows the textarea straight away. - Mode flips back to preview automatically when AI regen completes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): treatment plan intro now reflects actual citation counts Was hardcoded as "This plan reduces the risk through 5 controls:" no matter what — but the 5 citations are a mix of controls (max 3), tasks (max 2), policies, and gaps. The user-visible Linked Work column shows the underlying transitive control count (often 6+), so the prose said "5 controls" while the column showed "6 controls" → confusion. New `buildCitationsHeading` counts each citation kind and builds a grammatically correct intro via Intl.ListFormat: "This plan addresses the risk through 3 controls, 1 task, and 1 policy:" "This plan addresses the risk through 1 control and 1 task:" "This plan addresses the risk through 2 recommended gaps:" Extracted to its own pure module so the unit tests don't need to load the DB client (which `onboard-organization-helpers.ts` imports at the top level). 7 tests cover singular/plural, multi-kind ordering, gap- only fallback, and empty input. Existing risks/vendors keep their old prose until next regeneration — the fix applies whenever the user clicks "Regenerate with AI". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): heading reports full linked totals, not citation counts The previous heading counted citations (capped at 3 controls + 2 tasks), so a risk with 6 linked controls + 8 linked tasks showed "3 controls and 2 tasks" — contradicting the Linked Work column that displays the full 6+8. Plumb the full linked totals from the grounding context (which already loads them for the LLM prompt) through to `buildCitationsHeading`. The heading now reports those totals and labels the bullets as "Highlights below:" when they're a strict subset of what's linked: 6 controls + 8 tasks linked, 5 citations: "This plan addresses the risk through 6 controls and 8 tasks. Highlights below:" 2 controls + 1 task linked, 3 citations covering all: "This plan addresses the risk through 2 controls and 1 task:" Falls back to citation-kind counts when nothing is linked (e.g. only gaps and policy citations exist). Tests updated for the new signature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(risks): live progress while AI regenerates the treatment plan Previously the regenerate-mitigation flow was fire-and-forget from the UI's perspective: POST returned immediately after triggering the trigger.dev task, the editor's "AI is drafting" message disappeared, and users had to refresh manually 30-60s later to see the new prose. Now the same realtime pattern as auto-link: - POST `/regenerate-mitigation` (risk + vendor) returns the trigger.dev runId + a 15-min public access token alongside the trigger. - `DescriptionEditor` accepts a `regenRun` handle, subscribes via `useRealtimeRun`, and renders status-specific copy: - WAITING_FOR_DEPLOY → "Starting AI scan…" - QUEUED / DELAYED → "Queued — waiting to start…" - INTERRUPTED → "Resuming…" - EXECUTING → "AI is drafting your treatment plan…" - New `RegenProgress` component renders a small status card with a spinner, headline, sub-line, and the "you can keep editing; your edits will win" reassurance. - On COMPLETED → parent clears the run handle, refetches the risk/ vendor, shows a success toast, and the new markdown rendering kicks in automatically. - On terminal failure (FAILED/CRASHED/CANCELED/TIMED_OUT/EXPIRED/ SYSTEM_FAILURE) → parent clears the handle and surfaces a status-specific error toast. Also memoizes `handleRegenSettled` in both parents so the editor's `useEffect([status])` doesn't re-fire on parent re-renders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(risks): drop hero narrative one font size (text-base → text-sm) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): table residual column derives from strategy, matching the hero The risks table was rendering `RiskScoreBadge` from the stored `residualLikelihood` / `residualImpact` fields, while the Treatment Plan hero shows the strategy-derived target via `previewResidual`. For risks where the stored residual disagreed with the active strategy (the common case after a strategy change), the table and the hero showed different residuals. Compute the table's residual the same way the hero does: pass the risk's likelihood + impact + strategy through `previewResidual` and render the resulting target. Also expands the `@db` mock in two existing test files (RisksTable and RiskPageClient) so suggested-residual / risk-score helpers can load — the test suites that were exercising tied-to-DB enums now get the values they need. RisksTable suite is fully green; RiskPageClient was already failing on unrelated mock gaps before this change and still is — the mock expansion is strictly additive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(risks): independent description per treatment strategy Switching from Mitigate to Accept (or Transfer) used to leave the previous strategy's bullet-style plan visible under "Rationale" — wrong content for the new strategy. Now each strategy has its own saved text that's swapped in/out of the active `treatmentStrategyDescription` field as the user changes strategy. Schema: - Adds `strategyDescriptions Json?` to Risk and Vendor. Stores { mitigate?, accept?, transfer?, avoid? } so a Mitigate plan, an Accept rationale, and a Transfer rationale can coexist on one row. - Migration backfills the column from the existing `treatmentStrategyDescription` keyed by the row's current strategy. API (NestJS): - `risks.service.updateById` and `vendors.service.updateById` now resolve strategy/description changes through a shared helper: - Strategy change: save current text into the OLD slot, load the NEW slot's saved text into the active field (or null if empty). - Description change: mirror into the active strategy's slot. - Both: strategy swap runs first; explicit description wins. - New `apps/api/src/risks/strategy-descriptions.ts` is the single source of truth for the swap logic; vendors imports it. - 8 unit tests cover swap, mirror, both-changing, empty-clears, malformed-map, and the no-op cases. Trigger task (regenerate-mitigation): - After writing the LLM-generated text into `treatmentStrategyDescription`, also mirror it into `strategyDescriptions[<currentStrategy>]` so the saved text persists across strategy switches without an extra DB roundtrip from the API. UI: - No changes. The frontend continues to read `treatmentStrategyDescription`; the API swap ensures it's always the active strategy's text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): swap displayed description instantly when strategy changes The API now keeps per-strategy text in `strategyDescriptions` and swaps into `treatmentStrategyDescription` on strategy change, but the UI was still sourcing the editor's value from the entity's active field. Result: optimistically flipping local strategy left the previous strategy's content visible in the editor for the SWR revalidation window (and indefinitely if the user kept editing in that window). Plumb `strategyDescriptions` through to the treatment plan tab and derive the displayed description as: strategy === entity.treatmentStrategy ? entity.treatmentStrategyDescription // active row : entity.strategyDescriptions?.[strategy] // saved per-strategy ?? '' // empty if none yet So Mitigate → Accept now instantly shows the Accept rationale (or empty), not the Mitigate plan. Saved Accept text is preserved if the user flips back. Wired identically on Risk and Vendor pages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): hero level label matches score band, not raw L×I band The hero showed "from HIGH to HIGH" for a 7→5 risk reduction even though the 5 lands visually in the Medium segment of the RiskScale bar below it. Cause: level was derived from raw (likelihood × impact, 1-25 scale) where 12 is in the high band (>9), but the user-facing score is the normalized 1-10, where 5 is in the medium band (5-6). The two scales use different bucket sizes: raw thresholds → very-low ≤1, low 2-4, medium 5-9, high 10-16, very-high 17+ score buckets → very-low 1-2, low 3-4, medium 5-6, high 7-8, very-high 9-10 Add `getRiskLevelFromScore` in `lib/risk-score.ts` (mirrors RiskScale's 5 visual segments) and use it in `TreatmentHero` for the inherent, target, and current-interpolated level labels. Drops the `approximateRawFromScore` workaround that was bridging the gap with half-precision raw guesses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): coverage gate — no claimed reduction without linked work Without at least one linked task, a Mitigate or Transfer risk used to still show its full strategy ceiling as the target ("7 → 5") because the math was strategy-derived only. The strategy alone isn't audit evidence — projecting a -2 swing with nothing linked failed the "explain this to an auditor" sniff test. Add an `hasLinkedWork` flag to `previewResidual`. When the entity has no linked tasks AND the strategy isn't Accept (which is inherent by definition), the function returns `inherent` as the target. Mitigate and Transfer now collapse to no-change until at least one task is linked. TreatmentHero passes `hasLinkedWork: tasks.length > 0`. The narrative copy switches to a coverage-gate explanation when active: - Mitigate: "...until tasks supporting the strategy are linked." - Transfer: "...until a task documenting the transfer arrangement is linked." - Accept: unaffected (target is inherent regardless). Score explainer popover gets a new "Coverage gate" section so the methodology stays published. Tests: existing strategy-derived test now passes a linked task so the gate is satisfied; new test asserts that 0-tasks Mitigate shows 7 → 7 plus the gate copy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): risk matrix cells colored by score band, not raw heat Cells were colored by `(L_idx + I_idx) / 8` heat thresholds — a geometric/raw heuristic that didn't agree with the 1-10 score banding the headline numeral and the bottom RiskScale use. A target landing on (Possible × Moderate) showed a yellow cell while the headline "4/10" read green (Low) and the bottom scale tick was in the green Low segment. Compute each cell's normalized score (ceil(L*I / 2.5)) and bucket it through the same `getRiskLevelFromScore` thresholds. Cell backgrounds mirror RiskScale's 5 segments so the matrix, the headline color, and the bottom scale all agree on what counts as Low / Medium / High. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): "Now" marker on matrix tracks task completion The Now (red) marker was pinned to the inherent cell regardless of how much of the linked treatment plan was complete — so the matrix said "in High territory" while the headline showed Currently 6/10 (Medium) and 50% complete. Visually inconsistent with the headline. Add a `completion` prop (0..1, default 0) to RiskMatrix5x5 and render the Now dot at a position interpolated between the inherent cell and the residual target cell: nowL = inherentL + (targetL - inherentL) × completion nowI = inherentI + (targetI - inherentI) × completion Lifted the dot out of the cell loop into an absolutely-positioned overlay so it can sit between cells when partial completion lands it off-grid. 200ms ease-out transition on left/top for a smooth slide as tasks tick toward done. TreatmentHero passes `completion` (already computed for the headline interpolation) so the matrix and the headline numeral now move together. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): explicit overlap state when Now reaches Target At 100% completion the red Now dot was rendering on top of the green Target dot, hiding the win. Make the merged state visually distinct: swap to a single primary-colored disc with a centered Checkmark icon and a soft primary halo, signaling "reached target — plan complete." Trigger condition is `completion >= 1 AND target ≠ inherent`, so Accept-from-day-one risks (where dots overlap because the strategy projects no reduction) keep the standard two-dot rendering rather than claiming a "completed" reduction that didn't exist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): hero numeral color uses same tokens as the RiskScale bar The numeral colors were bespoke `oklch(...)` values while the bar segments below used `--success` / `--warning` / `--destructive` blends. They drifted out of agreement — the big "4/10" rendered in one shade of green, the Low segment of the bar in a different one. Switch the level-color map to opaque mixes of the same tokens the bar uses, in matching hue ratios: very-low → success low → mix(success 50%, warning) medium → warning high → mix(warning 50%, destructive) very-high → destructive Same colors propagate to the strong-tag spans in the narrative and the score tick on the RiskScale (the tick reads from LEVEL_COLOR via the existing `inherentColor` / `residualColor` props), so the numeral, narrative, tick, and segment now all live in the same color family. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): single risk-score column reflecting current treatment state The list view had two near-duplicate columns (SEVERITY + RESIDUAL RISK) that resolved to the same value for any risk without active treatment progress — which is most rows in onboarding. Drop SEVERITY entirely and rename the surviving column to RISK SCORE, showing the *current* treatment-aware score (interpolated between inherent and the strategy target by linked-task completion). The detail page hero retains the full inherent → target breakdown for users who want the breakdown. Backend: include `tasks: { id, status }` on the risks list response so the table can compute interpolated current scores without an extra roundtrip per row. Color/level cleanup: - Extract LEVEL_COLOR (var(--success) / var(--warning) / var(--destructive) blends) to lib/risk-score.ts so the hero, badge, scale, and matrix share one color source. - RiskScoreBadge now accepts `score: number` directly, derives level via getRiskLevelFromScore (matching the visual band thresholds), and uses the shared LEVEL_COLOR via inline `--band` CSS variable. Drops the bespoke Tailwind palette classes that drifted from the bar. - Replace the inline `getSeverityBadge` (its own custom thresholds, raw-based) with RiskScoreBadge so all callers go through one styled source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): add SEVERITY label column back, both columns reflect current state Restore the SEVERITY column for at-a-glance triage but reframe both visible columns to describe the *current* (treatment-aware) state of each risk: SEVERITY — qualitative level chip ("Low" / "Medium" / "High" / "Critical" / "Very low") for fast scanning RISK SCORE — precise 1-10 numeric for the same current state Both badges read from `currentSeverityScore(risk)` (interpolated between inherent and the strategy target by linked-task completion), share the same `--band` color, and stay perfectly in sync as treatment progresses. The detail page hero retains the full inherent → target breakdown for users who want the journey, not just the current snapshot — a 7→4 reduction is visible there once they click in. `RiskScoreBadge` gains a `labelOnly` prop that swaps the rendered text from "X/10" to the level label while keeping the same color treatment, enabling the two-format presentation from a single component. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): drop colored chip on SEVERITY column — plain text label Two colored chips of the same band on every row read as visual noise because they always carry the same color signal. Keep the colored chip on RISK SCORE (the precise number is what changes at a glance) and render SEVERITY as plain text (Low / Medium / High / etc.) — gives the qualitative read without doubling the color load. Move LEVEL_LABEL alongside LEVEL_COLOR in lib/risk-score.ts so any caller that needs the human-readable name can grab it without going through the badge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(risks): add Severity / Status / Owner filters to the risks list Three filter dropdowns next to the search bar, each URL-backed via nuqs so filter state survives reload and is shareable: - Severity (Very low / Low / Medium / High / Very high) - Status (Open / Pending / Closed / Archived) - Owner (each assigned member, populated from the existing `assignees` prop) Status and Owner pass through to the existing `useRisks` query params (server-side filter on the risks API). Severity is computed from the current treatment-aware score, so it can't be queried by the API — applied client-side after the fetch. Adds a "Clear filters" ghost button visible whenever any filter or search term is active, so users can reset back to the unfiltered list with one click. Test mocks extended with the DS Select primitives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): filter dropdowns render the selected label, not the raw value base-ui's Select.Value doesn't auto-resolve to a SelectItem's label — without a render function it shows the raw `value` (so "all" / "high" / "open" appeared instead of "All severities" / "High" / "Open"). Pass a `(value) => label` render-prop on each filter's SelectValue: - Severity → uses LEVEL_LABEL ("Very low" / "Low" / etc.) - Status → new local STATUS_LABEL map - Owner → looks up the assignee in `assignees` and renders user.name (falls back to email, then "Unknown") When the value is "all" (or unset), each trigger renders the "All severities / All statuses / All owners" copy instead of the raw sentinel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): tighten Owner filter to roles that can actually own risks The Owner dropdown was populated from the people endpoint with the filter !['employee','contractor'].includes(p.role). Two gaps: 1. `auditor` slipped through — auditors are read-only, can't update or own a risk. 2. Comma-separated multi-role values (e.g. 'admin,employee') broke the includes() check, so portal-only members with a multi-role string were appearing as eligible owners. Replace the role filter with a `canOwnRisks` helper that splits the comma-separated role field, allows owner / admin / any custom role, and explicitly excludes auditor / employee / contractor. Custom roles pass through because the org defines them — we can't know their resolved permissions client-side, and being conservative here would exclude legitimate custom "Risk Manager" / "GRC Lead" style roles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): pass `permission: undefined` alongside `permissions` to satisfy zod 4 better-auth's hasPermission endpoint validates the body with a zod union of two variants — one shaped { permission, permissions: undefined } and the other { permission: undefined, permissions }. Under zod 4, `z.undefined()` requires the key to be explicitly present with the undefined value, not absent. Sending only `{ permissions }` (key permission absent) fails BOTH variants with "[body] Invalid input" at runtime, even though the value-level shape was correct. Result: every permission-gated endpoint hit by an authenticated user threw a 400 "VALIDATION_ERROR" inside the guard, which the guard caught and re-threw as 403 ForbiddenException. Frontend pages reading gated endpoints (e.g. GET /v1/frameworks?includeControls=true) failed to load. Fix: build the body via a named variable (`{ permissions, permission: undefined }`) so TS's excess-property check — which only fires on object literals — doesn't reject the extra key, and the runtime union schema gets the both-keys-explicitly-present shape it requires. Reproduced with `zod` v4 against the exact schema in node_modules/better-auth: only the both-keys form passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(risks): paginate auto-link Tasks/Controls lists at 10 per page A scan that returns 30+ suggestions made the column grow taller than the viewport. Paginate both lists at 10 per page with prev/next controls and a "X-Y of N" range label. Pagination chrome only renders when there's more than one page (small lists stay clean). The page resets to the last valid page if the underlying list shrinks (re-run, items removed) so the user doesn't see an empty page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks): autonomous linkage now uses recall+rerank; mitigation leaves status pending Three related post-onboarding UX fixes. 1. Autonomous (onboarding) linkage was leaving most risks empty. The autonomous path used the strict default 0.65 cosine threshold, which lets through almost nothing in the 0.4-0.6 band that dominates short compliance prose. Result on a fresh org: 9 of 11 risks had zero linked tasks after onboarding, leaving the user with an empty kickoff state on every risk. Apply the same recall+rerank pipeline that suggestions-only uses: top-50 cosine candidates, top-30 fed to the LLM reranker, persist any match scored ≥ 5/10 (medium-relevance and up), capped at 8 per risk. Conservative because the user isn't reviewing — false positives stick until manually unlinked. Falls back to cosine ordering if the rerank call fails. 2. `generate-risk-mitigation` no longer marks risks `closed`. Auto-closing implies the user reviewed the AI plan, which they didn't. Set status to `pending` instead so the risk surfaces in "needs review" lists. Reassignment to owner/admin behavior is unchanged. 3. UI placeholder while auto-mitigation is in flight. When a Mitigate-strategy risk has linked tasks but no description yet (the auto-link → mitigation gap), show a "AI is preparing your treatment plan…" indicator instead of the empty editor. Polling (already 5s) refreshes when the description fills in. Heuristic-based so no new schema; the condition naturally resolves once the plan arrives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(risks,perf): baseline risk status pending; bump linkage concurrency to 16 Two follow-ups from the post-onboarding test on the new org: 1. The hardcoded baseline "Intentional Fraud and Misuse" risk in `ensureBaselineRisks` was being created with status `closed` — that's the same review-skipping behavior the trigger task fix already corrected for AI-generated risks. Now created with status `pending` for consistency. 2. `MATCH_CONCURRENCY` was set conservatively at 4. Each iteration is ~3-5s (vector query + LLM rerank + Prisma update), so a 12-risk onboarding was taking 3+ batches when one of the bottlenecks. Bump to 16 — well within Upstash + OpenAI rate limits for typical onboarding sizes — so 12-25 entities finish in roughly 2 batches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(linkage): bump MATCH_CONCURRENCY 16→32 to unblock onboarding A 12-risk + 11-vendor onboarding was taking 287s in the linkage step (82s risks + 203s vendors), which blocked the entire mitigation fan-out behind it (orchestrator uses triggerAndWait on linkage so mitigation generation can use the freshly-linked tasks/controls for grounded prose). Each iteration is dominated by the LLM rerank call (~5-10s). Going from 4→32 in-flight cuts the wall-clock to roughly 1 batch, well within gpt-5-mini and Upstash rate limits for typical onboarding sizes (10-25 entities). Note: the right architectural fix is true fan-out per entity using trigger.dev's queue-level concurrency (50), splitting runLinkage into embedScope() + matchEntity(). Filed as a follow-up — this concurrency bump is the immediate unblocker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(linkage): floor of 3 links per entity to avoid zero-result onboardings For "Phishing and Social Engineering Attacks" on a fresh org with 71 relevant tasks (Training / Competence Records, 2FA, Incident Response, Access Review Log, etc.), autonomous linkage persisted zero matches because the reranker scored every candidate below 5/10. Result: the risk landed in the kickoff empty state and looked like nothing happened. The min-score-5 gate was too strict on its own. Add a floor: if fewer than 3 candidates score ≥ 5/10, fall back to the top-3 by reranker score regardless of magnitude. The user reviews via the Linked Work column — better to start with 3 decent matches than zero. The high-confidence path is unchanged (≥ 5/10 wins, capped at 8). The floor only kicks in for niche risks where the LLM was conservative. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(linkage): per-risk diagnostic logs to surface zero-result causes When suggestions return 0 tasks/controls there's no way to tell from the trigger.dev console whether the cosine query came back empty, the scope filter dropped everything, or the reranker scored nothing high enough. Add structured per-iteration logs: [linkage] risk "X" → cosine returned N candidates (top scores: a, b, c) [linkage] risk "X" → M of N cosine matches dropped (not in task scope) [linkage] risk "X" → suggestions: A tasks, B controls [linkage] risk "X" → persisted N task link(s) [linkage] risk "X" → 0 candidates after linkSuggestions; skipping rerank Same shape will be added to vendor matching as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: add project-level .mcp.json for trigger.dev MCP server Anyone with Claude Code working on this repo gets the trigger.dev tools (runs, logs, deploys, docs search) without per-user setup. After pulling, restart Claude Code; the first authenticated tool call will prompt for trigger.dev login. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(linkage): wait for vector index + content-hash dedup on re-embeds Two production fixes confirmed end-to-end on a fresh org: 1. Race-condition wait. Upstash Vector returns 200 from upsert before the HNSW index has ingested the write, so cosine queries that race ahead silently return zero candidates. Hit during onboarding when 6 of 11 risks landed with 0 task links because their queries fired ~4s after upsert vs the next 5 risks ~5s after. Adds `waitForIndexed` helper that polls `info().pendingVectorCount` until it drains, plus a new `waiting-for-index` LinkagePhase. After the fix, the same onboarding flow links 11/11 risks (drain takes ~1.1s, 4 polls). 2. Content-hash dedup. Re-embedding every task/risk/vendor on every linkage run was the main driver of the recent OpenAI + Upstash cost spike. Adds `embeddingHash String?` to Task/Risk/Vendor (hash of model + dims + department + text) and skips both the OpenAI embed AND the Upstash upsert when the stored hash matches. First linkage run on a fresh org: `tasks 71 new / 0 cached`. Subsequent Suggest click on the same org: `tasks 0 new / 71 cached; skipping index drain wait` — zero billed embedding work. Tests: 4 new unit + 2 new integration in embedding/, including a race-condition guard that pins the order (findSimilarTasks does not fire until waitForIndexed resolves) and a cache-skip integration test that asserts the wait is bypassed when nothing was upserted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(docs): regenerate openapi.json from API start Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address Cubic AI review findings on PR #2671 Addresses 32 of 32 outstanding Cubic findings across security, ENG-221 spec compliance, stale-closure bugs, error message hygiene, and perf/correctness. P1 (security/correctness): - Cross-org task validation in risks/vendors auto-link/apply routes before set/connect — prevents linking another org's tasks (#2, #3). - Permission guards on every Next API mutation route via new requireApiPermission helper (#9). - Active-route token mint failures only clear the runId on confirmed "run gone" cases (404 / "not found"); transient failures return 502 and preserve the runId so the next attempt can resume (#4, #5). - Vendor mitigation now triggers AFTER the linkage gate so vendor AI generation sees the linked tasks/controls grounding (#7, #26). - RelinkButton completion effect reads link counts from typed `run.output` instead of metadata, eliminating stale-closure reports of 0 links (#6). - Prisma client refuses to connect to non-local Postgres without TLS verification unless PRISMA_ALLOW_INSECURE_TLS=1 is set explicitly. Localhost detection now uses the parsed URL hostname, not a regex over the full connection string (#1, #10). ENG-221 spec violations: - Avoid is now a first-class strategy option in the picker (#36). - ScoreExplainer mentions Avoid in the coverage gate (#35). - suggestedResidual gates Avoid on linked-work coverage (#39). - Linked Work column shows for non-mitigate strategies (#37). Stale-closure / state-race bugs: - AutoLinkSuggestions.parts useEffect uses refs for output and callbacks so the COMPLETED transition reads fresh values (#32). - AutoLinkSuggestions resume callback no longer overwrites a newer state transition (e.g. user-started suggest run) (#33). - onUnlinkTask prop wired to LinkedWork with a per-row trash icon (#34). Error/UX hygiene: - 5 routes return generic 500 messages instead of raw error.message (#17, #18, #19, #20, #21). - DELETE routes verify the link exists before disconnect (#22). - DELETE routes fire-and-forget the treatment-plan refresh (#30, #31). - regenerate-mitigation tolerates token-mint failures after the run is already triggered, returning runId with null token so client retries don't start duplicate runs (#29). - AutoLinkButton surfaces a distinct toast when post-link refresh fails, separately from link failures (#23). Determinism + scope correctness: - Citation grounding loaders add orderBy on tasks, controls, and requirementsMapped so mitigation citations are stable across re-runs (#40). - RiskPageClient falls through to server-rendered initialRisk.tasks so Linked Work doesn't blink empty between SSR and SWR (#28). - RisksTable severity filter now fetches the org's full risk set when active so filtering and pagination metadata are correct globally (#27). - embedding/index.ts marked `import 'server-only'`. vitest setup stubs the module so existing tests still run (#38). Test fixes: - link-suggestions "candidate department is none" test now actually asserts that the boost rule excludes `none` by setting source department to `none` and proving the un-boosted candidate wins (#24). - suggested-residual avoid test updated to cover both the empty-tasks (no operational evidence yet) and linked-tasks (pin to floor) cases. Tests: 43/43 in src/lib/{link-suggestions,suggested-residual,embedding}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address Cubic AI follow-up review on PR #2671 8 findings across stale-state bugs, defense-in-depth gating, shared-component reuse, and tooling hygiene. P1: - DescriptionEditor no longer wipes in-progress edits when `value` changes mid-typing — the resync effect now skips while mode is 'edit', not just while saving. This was overwriting user input whenever SWR revalidated or AI regen completed. - onUnlinkTask wiring on RiskPageClient + VendorDetailTabs is now gated behind `canUpdate` so the trash button doesn't render for read-only users. The server-side Next API routes already enforce the same `risk:update` / `vendor:update` check (added in the prior commit) — this is defense in depth. P2: - TreatmentHero: `isEmpty` narrative branch now wins over `isGatedByCoverage`. Since `isEmpty` is a strict subset (Mitigate + no plan + no linked work) of `isGatedByCoverage`, the empty message was being shadowed and never rendered. - AutoLinkSuggestions: applying with no selections in additive mode no longer flips the UI back to the empty state — landing state now considers BOTH existing linked tasks AND just-applied selections, gated on whether mode was reassess (replace=true). - link-risks-and-vendors-to-work trigger task: phase metadata now always writes every field (using null when absent in the new phase) so stale `current`/`total`/link counts don't leak across phase boundaries to the realtime UI subscriber. - VendorsTable residual sort: unassessed vendors are forced to the end of the list regardless of sort direction. Their default (very_unlikely × insignificant = 1) was clustering them as lowest-residual even though we render them as `—`. - NotAssessedState component is now neutral by default and accepts description/headline/ctaLabel overrides. The vendor caller passes its own copy. Lets the component be reused on the risk surface without leaking vendor-only language. P3: - .mcp.json (root + worktree) switches `npx` → `bunx` per repo tooling convention. Tests: 43/43 in src/lib/{link-suggestions,suggested-residual,embedding}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): hoist hasPermission body to a variable to satisfy TS excess-property check Inline form was rejected by tsc on this branch: Object literal may only specify known properties, but 'permission' does not exist in type ... Excess-property check only fires on fresh object literals; widening through a `body` variable accepts the wider runtime shape that the zod union schema actually requires. Same intent as the main hot-fix — both paths still pass `permission: undefined` explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Mariano <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8247ed3 commit 1a97746

118 files changed

Lines changed: 11278 additions & 671 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.mcp.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"mcpServers": {
3+
"trigger": {
4+
"command": "bunx",
5+
"args": ["trigger.dev@latest", "mcp"]
6+
}
7+
}
8+
}

apps/api/prisma/client.js

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,66 @@
22
Object.defineProperty(exports, "__esModule", { value: true });
33
exports.db = void 0;
44
const client_1 = require("@prisma/client");
5+
const adapter_pg_1 = require("@prisma/adapter-pg");
56
const globalForPrisma = global;
6-
exports.db = globalForPrisma.prisma || new client_1.PrismaClient();
7+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
8+
function stripSslMode(connectionString) {
9+
const url = new URL(connectionString);
10+
url.searchParams.delete('sslmode');
11+
return url.toString();
12+
}
13+
function isLocalhostUrl(connectionString) {
14+
try {
15+
const { hostname } = new URL(connectionString);
16+
// Strip square brackets from IPv6 host form (e.g. [::1] → ::1)
17+
const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
18+
return LOCAL_HOSTNAMES.has(stripped);
19+
}
20+
catch {
21+
// Malformed URL — be conservative and treat as remote so we don't
22+
// accidentally disable TLS verification.
23+
return false;
24+
}
25+
}
26+
function createPrismaClient() {
27+
const rawUrl = process.env.DATABASE_URL;
28+
const isLocalhost = isLocalhostUrl(rawUrl);
29+
// Strategy:
30+
// - Localhost: TLS off (typical dev Postgres has no cert).
31+
// - Remote with NODE_EXTRA_CA_CERTS set: verified TLS using that bundle
32+
// (e.g. Docker with the RDS CA bundle baked in).
33+
// - Remote in explicit opt-out mode (PRISMA_ALLOW_INSECURE_TLS=1):
34+
// unverified TLS — used by Trigger.dev / Vercel envs that connect via
35+
// a tunneled proxy whose cert can't be pinned. Must be set deliberately;
36+
// the previous default ("just turn off verification") silently exposed
37+
// prod connections to MITM. (Cubic finding #1 on PR #2671.)
38+
// - Remote with neither: throw at boot — surface the misconfig instead of
39+
// silently downgrading.
40+
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
41+
const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';
42+
let ssl;
43+
if (isLocalhost) {
44+
ssl = undefined;
45+
}
46+
else if (hasCABundle) {
47+
ssl = true;
48+
}
49+
else if (allowInsecure) {
50+
ssl = { rejectUnauthorized: false };
51+
}
52+
else {
53+
throw new Error('Refusing to connect to a non-local Postgres without TLS verification. Set NODE_EXTRA_CA_CERTS to a CA bundle, or set PRISMA_ALLOW_INSECURE_TLS=1 if you intentionally want unverified TLS.');
54+
}
55+
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
56+
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
57+
const adapter = new adapter_pg_1.PrismaPg({ connectionString: url, ssl });
58+
return new client_1.PrismaClient({
59+
adapter,
60+
transactionOptions: {
61+
timeout: 60000,
62+
},
63+
});
64+
}
65+
exports.db = globalForPrisma.prisma || createPrismaClient();
766
if (process.env.NODE_ENV !== 'production')
867
globalForPrisma.prisma = exports.db;
9-
//# sourceMappingURL=client.js.map

apps/api/prisma/client.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,55 @@ import { PrismaPg } from '@prisma/adapter-pg';
33

44
const globalForPrisma = global as unknown as { prisma: PrismaClient };
55

6+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
7+
68
function stripSslMode(connectionString: string): string {
79
const url = new URL(connectionString);
810
url.searchParams.delete('sslmode');
911
return url.toString();
1012
}
1113

14+
function isLocalhostUrl(connectionString: string): boolean {
15+
try {
16+
const { hostname } = new URL(connectionString);
17+
// Strip square brackets from IPv6 host form (e.g. [::1] → ::1)
18+
const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
19+
return LOCAL_HOSTNAMES.has(stripped);
20+
} catch {
21+
// Malformed URL — be conservative and treat as remote so we don't
22+
// accidentally disable TLS verification.
23+
return false;
24+
}
25+
}
26+
1227
function createPrismaClient(): PrismaClient {
1328
const rawUrl = process.env.DATABASE_URL!;
14-
const isLocalhost = /localhost|127\.0\.0\.1|::1/.test(rawUrl);
15-
// Use verified SSL when NODE_EXTRA_CA_CERTS is set (Docker with RDS CA bundle),
16-
// otherwise fall back to unverified SSL (Trigger.dev, Vercel, other environments).
29+
const isLocalhost = isLocalhostUrl(rawUrl);
30+
// Strategy:
31+
// - Localhost: TLS off (typical dev Postgres has no cert).
32+
// - Remote with NODE_EXTRA_CA_CERTS set: verified TLS using that bundle
33+
// (e.g. Docker with the RDS CA bundle baked in).
34+
// - Remote in explicit opt-out mode (PRISMA_ALLOW_INSECURE_TLS=1):
35+
// unverified TLS — used by Trigger.dev / Vercel envs that connect via
36+
// a tunneled proxy whose cert can't be pinned. Must be set deliberately;
37+
// the previous default ("just turn off verification") silently exposed
38+
// prod connections to MITM. (Cubic finding #1 on PR #2671.)
39+
// - Remote with neither: throw at boot — surface the misconfig instead of
40+
// silently downgrading.
1741
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
18-
const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false };
42+
const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';
43+
let ssl: undefined | true | { rejectUnauthorized: false };
44+
if (isLocalhost) {
45+
ssl = undefined;
46+
} else if (hasCABundle) {
47+
ssl = true;
48+
} else if (allowInsecure) {
49+
ssl = { rejectUnauthorized: false };
50+
} else {
51+
throw new Error(
52+
'Refusing to connect to a non-local Postgres without TLS verification. Set NODE_EXTRA_CA_CERTS to a CA bundle, or set PRISMA_ALLOW_INSECURE_TLS=1 if you intentionally want unverified TLS.',
53+
);
54+
}
1955
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
2056
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
2157
const adapter = new PrismaPg({ connectionString: url, ssl });

apps/api/prisma/index.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,3 @@ exports.db = void 0;
1818
__exportStar(require("@prisma/client"), exports);
1919
var client_1 = require("./client");
2020
Object.defineProperty(exports, "db", { enumerable: true, get: function () { return client_1.db; } });
21-
//# sourceMappingURL=index.js.map

apps/api/src/auth/permission.guard.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,13 @@ export class PermissionGuard implements CanActivate {
187187
// the schema rejects every request with `[body] Invalid input`, the
188188
// catch in canActivate turns that into a generic "Unable to verify
189189
// permissions" 403, and EVERY cookie-authenticated request returns 403.
190-
// Reproduced repo-side via `bun run zod-repro.mjs`. Discovered on
191-
// ENG-221 and the same fix applies here.
192-
const result = await auth.api.hasPermission({
193-
headers,
194-
body: { permissions, permission: undefined },
195-
});
190+
//
191+
// Spell the body out via a separate variable so TypeScript's excess-
192+
// property check (only applied to fresh object literals) doesn't
193+
// reject the extra `permission` key — the runtime accepts the wider
194+
// shape per the union schema.
195+
const body = { permissions, permission: undefined };
196+
const result = await auth.api.hasPermission({ headers, body });
196197

197198
return result.success === true;
198199
}

apps/api/src/policies/policies.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1319,7 +1319,7 @@ Keep responses helpful and focused on the policy editing task.`;
13191319
];
13201320

13211321
const result = streamText({
1322-
model: openai('gpt-5.1'),
1322+
model: openai('gpt-5.5'),
13231323
system: systemPrompt,
13241324
messages: convertToModelMessages(messages),
13251325
});

apps/api/src/risks/risks.service.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { db, Prisma } from '@db';
88
import { CreateRiskDto } from './dto/create-risk.dto';
99
import { GetRisksQueryDto } from './dto/get-risks-query.dto';
1010
import { UpdateRiskDto } from './dto/update-risk.dto';
11+
import { resolveStrategyDescriptionUpdate } from './strategy-descriptions';
1112

1213
export interface PaginatedRisksResult {
1314
data: Prisma.RiskGetPayload<{
@@ -94,6 +95,10 @@ export class RisksService {
9495
},
9596
},
9697
},
98+
// Linked task statuses are needed by the table to compute the
99+
// current (interpolated) severity score so the badge reflects
100+
// treatment progress, not just inherent risk.
101+
tasks: { select: { id: true, status: true } },
97102
},
98103
}),
99104
db.risk.count({ where }),
@@ -128,6 +133,14 @@ export class RisksService {
128133
user: true,
129134
},
130135
},
136+
tasks: {
137+
select: {
138+
id: true,
139+
title: true,
140+
status: true,
141+
controls: { select: { id: true, name: true } },
142+
},
143+
},
131144
},
132145
});
133146

@@ -182,8 +195,8 @@ export class RisksService {
182195
updateRiskDto: UpdateRiskDto,
183196
) {
184197
try {
185-
// First check if the risk exists in the organization
186-
await this.findById(id, organizationId);
198+
// Need the existing row to resolve strategy/description swaps below.
199+
const existing = await this.findById(id, organizationId);
187200

188201
if (updateRiskDto.assigneeId) {
189202
await this.validateAssigneeNotPlatformAdmin(
@@ -192,9 +205,18 @@ export class RisksService {
192205
);
193206
}
194207

208+
// Keep per-strategy descriptions independent in the strategyDescriptions
209+
// JSON map: a Mitigate plan, an Accept rationale, and a Transfer
210+
// rationale all live alongside each other, swapped in/out of the active
211+
// `treatmentStrategyDescription` field as the user changes strategy.
212+
const resolvedStrategyFields = resolveStrategyDescriptionUpdate(
213+
existing,
214+
updateRiskDto,
215+
);
216+
195217
const updatedRisk = await db.risk.update({
196218
where: { id },
197-
data: updateRiskDto,
219+
data: { ...updateRiskDto, ...resolvedStrategyFields },
198220
});
199221

200222
this.logger.log(`Updated risk: ${updatedRisk.title} (${id})`);
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, expect, it, jest } from '@jest/globals';
2+
3+
// Mock @db before importing the helper so we don't pull in the real Prisma
4+
// client (which requires DATABASE_URL at module load).
5+
jest.mock('@db', () => ({
6+
RiskTreatmentType: {
7+
accept: 'accept',
8+
avoid: 'avoid',
9+
mitigate: 'mitigate',
10+
transfer: 'transfer',
11+
},
12+
}));
13+
14+
import { RiskTreatmentType } from '@db';
15+
import { resolveStrategyDescriptionUpdate } from './strategy-descriptions';
16+
17+
const baseExisting = {
18+
treatmentStrategy: RiskTreatmentType.mitigate,
19+
treatmentStrategyDescription: 'Current mitigate plan',
20+
strategyDescriptions: {} as unknown,
21+
};
22+
23+
describe('resolveStrategyDescriptionUpdate', () => {
24+
it('returns empty when neither strategy nor description is changing', () => {
25+
expect(resolveStrategyDescriptionUpdate(baseExisting, {})).toEqual({});
26+
expect(
27+
resolveStrategyDescriptionUpdate(baseExisting, { assigneeId: 'mbr_1' } as never),
28+
).toEqual({});
29+
});
30+
31+
it('on strategy change: saves current description into the OLD slot', () => {
32+
const result = resolveStrategyDescriptionUpdate(baseExisting, {
33+
treatmentStrategy: RiskTreatmentType.accept,
34+
});
35+
expect(result.treatmentStrategy).toBe(RiskTreatmentType.accept);
36+
expect(result.strategyDescriptions).toEqual({
37+
mitigate: 'Current mitigate plan',
38+
});
39+
// Active text becomes empty (no Accept rationale saved yet)
40+
expect(result.treatmentStrategyDescription).toBeNull();
41+
});
42+
43+
it('on strategy change: loads NEW strategy slot into active text when present', () => {
44+
const result = resolveStrategyDescriptionUpdate(
45+
{
46+
...baseExisting,
47+
strategyDescriptions: {
48+
accept: 'We accept this risk because of cost-benefit analysis.',
49+
},
50+
},
51+
{ treatmentStrategy: RiskTreatmentType.accept },
52+
);
53+
expect(result.treatmentStrategyDescription).toBe(
54+
'We accept this risk because of cost-benefit analysis.',
55+
);
56+
// Mitigate plan still preserved in the map
57+
expect(result.strategyDescriptions).toEqual({
58+
mitigate: 'Current mitigate plan',
59+
accept: 'We accept this risk because of cost-benefit analysis.',
60+
});
61+
});
62+
63+
it('on description change without strategy change: mirrors into active strategy slot', () => {
64+
const result = resolveStrategyDescriptionUpdate(baseExisting, {
65+
treatmentStrategyDescription: 'Updated mitigate plan',
66+
});
67+
expect(result.treatmentStrategy).toBeUndefined();
68+
expect(result.treatmentStrategyDescription).toBe('Updated mitigate plan');
69+
expect(result.strategyDescriptions).toEqual({
70+
mitigate: 'Updated mitigate plan',
71+
});
72+
});
73+
74+
it('on both change: explicit description wins as the new active text', () => {
75+
const result = resolveStrategyDescriptionUpdate(
76+
{
77+
...baseExisting,
78+
strategyDescriptions: {
79+
accept: 'Old accept rationale',
80+
},
81+
},
82+
{
83+
treatmentStrategy: RiskTreatmentType.accept,
84+
treatmentStrategyDescription: 'Brand-new accept rationale',
85+
},
86+
);
87+
expect(result.treatmentStrategy).toBe(RiskTreatmentType.accept);
88+
expect(result.treatmentStrategyDescription).toBe('Brand-new accept rationale');
89+
expect(result.strategyDescriptions).toEqual({
90+
mitigate: 'Current mitigate plan',
91+
accept: 'Brand-new accept rationale',
92+
});
93+
});
94+
95+
it('clears the slot when description is set to empty', () => {
96+
const result = resolveStrategyDescriptionUpdate(
97+
{
98+
...baseExisting,
99+
strategyDescriptions: { mitigate: 'old text' },
100+
},
101+
{ treatmentStrategyDescription: '' },
102+
);
103+
expect(result.strategyDescriptions).toEqual({});
104+
});
105+
106+
it('handles malformed strategyDescriptions gracefully', () => {
107+
const result = resolveStrategyDescriptionUpdate(
108+
{ ...baseExisting, strategyDescriptions: 'not an object' as unknown },
109+
{ treatmentStrategy: RiskTreatmentType.accept },
110+
);
111+
// Falls back to empty map; saves current Mitigate text
112+
expect(result.strategyDescriptions).toEqual({
113+
mitigate: 'Current mitigate plan',
114+
});
115+
});
116+
117+
it('does not save an empty old description into the slot', () => {
118+
const result = resolveStrategyDescriptionUpdate(
119+
{ ...baseExisting, treatmentStrategyDescription: '' },
120+
{ treatmentStrategy: RiskTreatmentType.accept },
121+
);
122+
expect(result.strategyDescriptions).toEqual({});
123+
});
124+
});

0 commit comments

Comments
 (0)