diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts
index 712e1c2e6a..349a2cff9a 100644
--- a/apps/api/src/vendors/vendors.service.ts
+++ b/apps/api/src/vendors/vendors.service.ts
@@ -107,6 +107,11 @@ export class VendorsService {
},
},
},
+ // Linked task statuses are needed by the vendors table to compute
+ // the current (interpolated) severity score so the residual badge
+ // reflects treatment progress, not just the static residual
+ // probability/impact. Mirrors the risks service.
+ tasks: { select: { id: true, status: true } },
},
});
diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx
index 42520e71f9..b748b39dc6 100644
--- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx
+++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx
@@ -625,7 +625,8 @@ export const RisksTable = ({
SEVERITY
- RISK SCORE
+ INHERENT RISK
+ CURRENT RISK
STATUS
OWNER
@@ -658,11 +659,19 @@ export const RisksTable = ({
{(() => {
- // Both columns describe the *current* treatment-aware
- // state. SEVERITY shows the qualitative level as
- // plain text (no chip — the colored chip is on RISK
- // SCORE, so a single visual signal carries the band
- // and the second column adds the precise number).
+ // Three score columns paint the before-vs-now picture:
+ // SEVERITY = current treatment-aware level (text).
+ // INHERENT = raw score before treatment, fixed.
+ // CURRENT = treatment-aware score interpolated by
+ // linked-task completion. Named "Current"
+ // (not "Residual") because the canonical
+ // residual is the *target* score at 100%
+ // completion — what's shown here moves
+ // with progress and matches the hero's
+ // "Currently X/10" subline.
+ // SEVERITY is plain text and CURRENT carries the
+ // colored chip so we don't double-paint the band.
+ const inherentScore = getRiskScore(risk.likelihood, risk.impact).score;
const score = currentSeverityScore(risk);
const level = getRiskLevelFromScore(score);
return (
@@ -670,6 +679,9 @@ export const RisksTable = ({
{LEVEL_LABEL[level]}
+
+
+
diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
index 962d7a4ea8..806fabd745 100644
--- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
+++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorsTable.tsx
@@ -6,6 +6,12 @@ import { VendorStatus } from '@/components/vendor-status';
import { usePermissions } from '@/hooks/use-permissions';
import { useVendors, useVendorActions, type Vendor } from '@/hooks/use-vendors';
import { getRiskScore } from '@/lib/risk-score';
+import {
+ interpolatedResidualScore,
+ previewResidual,
+ suggestedResidual,
+} from '@/lib/suggested-residual';
+import type { TaskStatus } from '@db';
import {
AlertDialog,
AlertDialogAction,
@@ -54,6 +60,40 @@ export type VendorRow = Vendor & {
isAssessing?: boolean;
};
+/**
+ * Mirrors `currentSeverityScore` in the risks table — projects the vendor's
+ * inherent + treatment-strategy + linked-task completion into the same
+ * interpolated 1–10 score the Treatment Plan hero shows. Falls back to
+ * inherent when there's no linked work or strategy doesn't reduce.
+ */
+function currentVendorSeverityScore(vendor: {
+ inherentProbability: VendorRow['inherentProbability'];
+ inherentImpact: VendorRow['inherentImpact'];
+ treatmentStrategy: VendorRow['treatmentStrategy'];
+ tasks?: Array<{ status: TaskStatus }>;
+}): number {
+ const inherent = getRiskScore(vendor.inherentProbability, vendor.inherentImpact);
+ const tasks = vendor.tasks ?? [];
+ const target = previewResidual({
+ inherentLikelihood: vendor.inherentProbability,
+ inherentImpact: vendor.inherentImpact,
+ strategy: vendor.treatmentStrategy,
+ hasLinkedWork: tasks.length > 0,
+ });
+ const targetScore = getRiskScore(target.likelihood, target.impact).score;
+ const completion = suggestedResidual({
+ likelihood: vendor.inherentProbability,
+ impact: vendor.inherentImpact,
+ strategy: vendor.treatmentStrategy,
+ tasks,
+ }).completion;
+ return interpolatedResidualScore({
+ inherentScore: inherent.score,
+ targetScore,
+ completion,
+ });
+}
+
type AssigneeMember = {
id: string;
role: string;
@@ -349,8 +389,11 @@ export function VendorsTable({
const aAssessed = a.status === 'assessed';
const bAssessed = b.status === 'assessed';
if (aAssessed !== bAssessed) return aAssessed ? -1 : 1;
- const aScore = getRiskScore(a.residualProbability, a.residualImpact).raw;
- const bScore = getRiskScore(b.residualProbability, b.residualImpact).raw;
+ // Sort by the SAME interpolated score the badge renders so the
+ // sort order matches what the user sees (treatment-progress
+ // aware), not the static residual fields.
+ const aScore = currentVendorSeverityScore(a);
+ const bScore = currentVendorSeverityScore(b);
const comparison = aScore - bScore;
return sort.desc ? -comparison : comparison;
}
@@ -571,7 +614,7 @@ export function VendorsTable({
onClick={() => handleSort('residualRisk')}
className="flex items-center hover:text-foreground"
>
- RESIDUAL RISK
+ CURRENT RISK
{getSortIcon('residualRisk')}
@@ -610,10 +653,12 @@ export function VendorsTable({
{vendor.status === 'not_assessed' ? (
—
) : (
-
+ // Show the current (interpolated) score that
+ // reflects how far the linked tasks have driven
+ // the residual down — same logic the risks table
+ // uses. Static residualProbability / Impact alone
+ // can't reflect mid-treatment progress.
+
)}
diff --git a/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx b/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx
index acd55266b4..becb4fd583 100644
--- a/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx
+++ b/apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx
@@ -113,19 +113,50 @@ export function DescriptionEditor({
// Regenerate-with-AI bypasses the in-edit guard above. When a regen run
// terminates (`regenRun` flips from set → null), the user explicitly
// asked to overwrite whatever they had — keeping the stale draft and
- // requiring a refresh to see the new prose was confusing. Force a
- // preview reset so the new value lands immediately.
+ // requiring a refresh to see the new prose was confusing.
+ //
+ // The new prose may already be in `value` at the moment regenRun
+ // clears (sync write before the parent flips the run handle), or it
+ // may arrive in a later render after SWR refetches. Both paths are
+ // handled:
+ //
+ // 1. Sync arrival: when regenRun flips set→null, immediately apply
+ // the current value and force preview.
+ // 2. Async arrival: capture the value-at-clear-time. The next render
+ // where `value` differs from the captured snapshot is the AI prose
+ // landing — apply it, force preview, and clear the latch.
+ //
+ // Without (2), a regen that completes BEFORE the SWR refetch would
+ // sync-apply the OLD value, and the new prose arriving moments later
+ // would be ignored because the in-edit guard skips resync while
+ // mode === 'edit'.
const prevRegenRunRef = useRef(regenRun);
+ const valueAtRegenClearRef = useRef(null);
useEffect(() => {
- const wasRunning = prevRegenRunRef.current !== null && prevRegenRunRef.current !== undefined;
- const isRunning = regenRun !== null && regenRun !== undefined;
+ const wasRunning = prevRegenRunRef.current != null;
+ const isRunning = regenRun != null;
prevRegenRunRef.current = regenRun;
if (wasRunning && !isRunning) {
+ // Path 1: sync arrival — value has already updated.
+ valueAtRegenClearRef.current = value;
setDraft(value);
if (value.trim().length > 0) setMode('preview');
}
}, [regenRun, value]);
+ useEffect(() => {
+ const captured = valueAtRegenClearRef.current;
+ if (captured === null) return;
+ if (value === captured) return;
+ // Path 2: async arrival — value just changed since regen cleared,
+ // so this is the AI prose landing. Overwrite even if user is in
+ // edit mode (they explicitly opted into the overwrite by clicking
+ // Regenerate).
+ valueAtRegenClearRef.current = null;
+ setDraft(value);
+ if (value.trim().length > 0) setMode('preview');
+ }, [value]);
+
// Auto-grow the textarea to fit content, but cap at TEXTAREA_MAX_PX so a
// long draft doesn't stretch the Treatment plan column past the Strategy
// / Linked Work columns. Internal scroll kicks in past the cap.
diff --git a/apps/app/src/lib/strategy-descriptions.ts b/apps/app/src/lib/strategy-descriptions.ts
index 6a2873c141..0f48161c79 100644
--- a/apps/app/src/lib/strategy-descriptions.ts
+++ b/apps/app/src/lib/strategy-descriptions.ts
@@ -27,3 +27,52 @@ export function mirrorActiveDescriptionIntoMap({
}
return map;
}
+
+/**
+ * Build the data fields to write when the AI mitigation generator emits a
+ * fresh treatment plan. Always lands the plan under the `mitigate` slot
+ * (the plan IS a mitigation plan), forces the active strategy to
+ * mitigate so the user sees the new plan in the correct column, and
+ * preserves any existing non-mitigate description under its own slot so
+ * the user's prior Accept / Transfer / Avoid rationale isn't lost.
+ *
+ * Used by both generate-risk-mitigation and generate-vendor-mitigation.
+ * Without this guarantee, entities created with a non-mitigate default
+ * have the AI plan stored under the wrong strategy and switching back
+ * to mitigate looks empty (the bug fixed in this commit).
+ */
+export function applyMitigationPlanFields({
+ plan,
+ currentStrategy,
+ currentDescription,
+ currentMap,
+}: {
+ plan: string;
+ currentStrategy: string;
+ currentDescription: string | null;
+ currentMap: unknown;
+}): {
+ treatmentStrategy: 'mitigate';
+ treatmentStrategyDescription: string;
+ strategyDescriptions: Record;
+} {
+ const map: Record = {};
+ if (currentMap && typeof currentMap === 'object' && !Array.isArray(currentMap)) {
+ for (const [k, v] of Object.entries(currentMap as Record)) {
+ if (typeof v === 'string' && v.length > 0) map[k] = v;
+ }
+ }
+ if (
+ currentStrategy !== 'mitigate' &&
+ currentDescription &&
+ currentDescription.length > 0
+ ) {
+ map[currentStrategy] = currentDescription;
+ }
+ map.mitigate = plan;
+ return {
+ treatmentStrategy: 'mitigate',
+ treatmentStrategyDescription: plan,
+ strategyDescriptions: map,
+ };
+}
diff --git a/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts
index 7399e9ee00..b67179d282 100644
--- a/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts
+++ b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts
@@ -17,7 +17,10 @@ import { generateObject, jsonSchema } from 'ai';
import axios from 'axios';
import { z } from 'zod';
import type { researchVendor } from '../scrape/research';
-import { mirrorActiveDescriptionIntoMap } from '@/lib/strategy-descriptions';
+import {
+ applyMitigationPlanFields,
+ mirrorActiveDescriptionIntoMap,
+} from '@/lib/strategy-descriptions';
import { buildCitationsHeading } from './build-citations-heading';
import { RISK_MITIGATION_PROMPT } from './prompts/risk-mitigation';
import {
@@ -667,11 +670,11 @@ ${formatCitationsBlock(citations)}`;
schema: sentencesSchema,
});
- const treatmentStrategy =
- typeof vendor.treatmentStrategy === 'string' ? vendor.treatmentStrategy : 'mitigate';
-
+ // See createRiskMitigationComment — pin the prose label to 'mitigate'
+ // since the saved strategy is forced to mitigate by
+ // `applyMitigationPlanFields` below.
const finalText = combineSentencesWithCitations({
- treatmentStrategy,
+ treatmentStrategy: 'mitigate',
sentences: result.object.sentences,
citations,
linkedTotals: {
@@ -680,20 +683,22 @@ ${formatCitationsBlock(citations)}`;
},
});
- // Mirror the new text into the per-strategy map so switching strategies
- // doesn't lose this draft.
- const vendorActiveStrategy =
- typeof vendor.treatmentStrategy === 'string' ? vendor.treatmentStrategy : 'mitigate';
+ // The AI generated a mitigation plan — force the strategy to mitigate
+ // so the plan lands in the correct slot, even if the vendor was
+ // previously on Accept / Transfer / Avoid (e.g. older rows created
+ // before the schema default flipped to mitigate). Any prior non-
+ // mitigate text is preserved under its own slot.
await db.vendor.update({
where: { id: vendor.id, organizationId },
- data: {
- treatmentStrategyDescription: finalText,
- strategyDescriptions: mirrorActiveDescriptionIntoMap({
- strategy: vendorActiveStrategy,
- description: finalText,
- current: vendor.strategyDescriptions,
- }),
- },
+ data: applyMitigationPlanFields({
+ plan: finalText,
+ currentStrategy:
+ typeof vendor.treatmentStrategy === 'string'
+ ? vendor.treatmentStrategy
+ : 'mitigate',
+ currentDescription: vendor.treatmentStrategyDescription ?? null,
+ currentMap: vendor.strategyDescriptions,
+ }),
});
logger.info(
@@ -1001,12 +1006,18 @@ export async function createRiskMitigationComment(
gapHint: GAP_HINT_BY_RISK_CATEGORY[risk.category] ?? 'general',
});
+ // The AI mitigation generator always produces a *mitigation* plan, and
+ // `applyMitigationPlanFields` below forces the saved strategy to
+ // 'mitigate'. Pin the prompt + prose label to 'mitigate' too so the
+ // generated text isn't labeled with the row's previous strategy
+ // (e.g. "Treatment plan (accept)" stored under strategy=mitigate).
+ const PLAN_STRATEGY = 'mitigate';
const userPrompt = `Risk: ${risk.title}
Description: ${risk.description}
Category: ${risk.category}
Department: ${risk.department ?? 'unspecified'}
Residual: likelihood=${risk.residualLikelihood}, impact=${risk.residualImpact}
-Treatment strategy: ${risk.treatmentStrategy}
+Treatment strategy: ${PLAN_STRATEGY}
Citations (write one sentence per item, in order):
${formatCitationsBlock(citations)}`;
@@ -1019,7 +1030,7 @@ ${formatCitationsBlock(citations)}`;
});
const finalText = combineSentencesWithCitations({
- treatmentStrategy: risk.treatmentStrategy,
+ treatmentStrategy: PLAN_STRATEGY,
sentences: result.object.sentences,
citations,
linkedTotals: {
@@ -1028,16 +1039,17 @@ ${formatCitationsBlock(citations)}`;
},
});
+ // See createVendorRiskMitigationComment — the AI plan is a mitigation
+ // plan, so force strategy=mitigate and preserve any prior non-mitigate
+ // description under its own slot.
await db.risk.update({
where: { id: risk.id, organizationId },
- data: {
- treatmentStrategyDescription: finalText,
- strategyDescriptions: mirrorActiveDescriptionIntoMap({
- strategy: risk.treatmentStrategy,
- description: finalText,
- current: risk.strategyDescriptions,
- }),
- },
+ data: applyMitigationPlanFields({
+ plan: finalText,
+ currentStrategy: risk.treatmentStrategy,
+ currentDescription: risk.treatmentStrategyDescription,
+ currentMap: risk.strategyDescriptions,
+ }),
});
logger.info(
@@ -1159,7 +1171,11 @@ export async function createRisksFromData(
metadata.set(`risk_temp_${index}_status`, 'processing');
});
- // Create all risks concurrently
+ // Create all risks concurrently. Strategy is intentionally NOT taken
+ // from the LLM extraction — we always start with mitigate (the
+ // workhorse strategy + schema default) so the AI mitigation plan that
+ // runs immediately after lands in the correct slot. The user can
+ // switch to accept / transfer / avoid manually if needed.
const createPromises = riskData.map((risk) =>
db.risk.create({
data: {
@@ -1169,8 +1185,6 @@ export async function createRisksFromData(
department: risk.department,
likelihood: risk.risk_residual_probability,
impact: risk.risk_residual_impact,
- treatmentStrategy: risk.risk_treatment_strategy,
- treatmentStrategyDescription: risk.risk_treatment_strategy_description,
organizationId,
},
}),
@@ -1220,6 +1234,9 @@ async function createRisksFromDataWithBaseline(
},
});
} else if (risk.riskData) {
+ // Same rationale as createRisksFromData — strategy is fixed to
+ // mitigate (schema default) so the AI plan generated right after
+ // lands in the correct slot.
return db.risk.create({
data: {
title: risk.riskData.risk_name,
@@ -1228,8 +1245,6 @@ async function createRisksFromDataWithBaseline(
department: risk.riskData.department,
likelihood: risk.riskData.risk_residual_probability,
impact: risk.riskData.risk_residual_impact,
- treatmentStrategy: risk.riskData.risk_treatment_strategy,
- treatmentStrategyDescription: risk.riskData.risk_treatment_strategy_description,
organizationId,
},
});
diff --git a/apps/app/src/trigger/tasks/onboarding/update-policy.ts b/apps/app/src/trigger/tasks/onboarding/update-policy.ts
index 5607c01b03..f17bcbafde 100644
--- a/apps/app/src/trigger/tasks/onboarding/update-policy.ts
+++ b/apps/app/src/trigger/tasks/onboarding/update-policy.ts
@@ -6,8 +6,17 @@ if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not set');
}
-// v4: define queue ahead of time
-export const updatePolicyQueue = queue({ name: 'update-policy', concurrencyLimit: 50 });
+// v4: define queue ahead of time.
+// concurrencyLimit is intentionally low so a 30+ policy onboarding fan-out
+// can't hog all of dev's 25-slot env budget. Each policy update takes ~20-40s
+// (LLM call + DB writes), while risk + vendor mitigations take ~8-12s. With
+// no cap the slower policies fill every slot first and the user-visible
+// mitigation tasks sit queued for minutes. Capping at 5 leaves ≥20 slots
+// for the faster, latency-sensitive mitigation fan-out to finish quickly,
+// then policies drain through. Total wall time is ~the same, but the user
+// sees their treatment plans populate immediately instead of waiting for
+// every policy to finish first.
+export const updatePolicyQueue = queue({ name: 'update-policy', concurrencyLimit: 5 });
export const updatePolicy = schemaTask({
id: 'update-policy',
diff --git a/apps/app/src/trigger/tasks/scrape/research.ts b/apps/app/src/trigger/tasks/scrape/research.ts
index a1c1e75cac..8681d5787a 100644
--- a/apps/app/src/trigger/tasks/scrape/research.ts
+++ b/apps/app/src/trigger/tasks/scrape/research.ts
@@ -1,8 +1,19 @@
import { researchJobCore } from '@/trigger/lib/research';
import { db } from '@db/server';
-import { schemaTask } from '@trigger.dev/sdk';
+import { queue, schemaTask } from '@trigger.dev/sdk';
import { z } from 'zod';
+// Each research run can hold a slot for minutes (firecrawl scrape + LLM
+// extraction). Without a cap, a 10-vendor onboarding can hog the whole
+// 25-slot dev env budget for the entire research window — starving the
+// mitigation + policy fan-outs that fire right after. Capping at 5 lets
+// research progress in batches while leaving 20 slots free for the rest
+// of the onboarding pipeline.
+const researchVendorQueue = queue({
+ name: 'research-vendor',
+ concurrencyLimit: 5,
+});
+
const firecrawlDataSchema = z.object({
company_name: z.string().optional().nullable(),
legal_name: z.string().optional().nullable(),
@@ -35,6 +46,7 @@ const firecrawlDataSchema = z.object({
export const researchVendor = schemaTask({
id: 'research-vendor',
+ queue: researchVendorQueue,
schema: z.object({
website: z.string().url(),
}),
diff --git a/packages/db/prisma/migrations/20260506130119_default_treatment_strategy_to_mitigate/migration.sql b/packages/db/prisma/migrations/20260506130119_default_treatment_strategy_to_mitigate/migration.sql
new file mode 100644
index 0000000000..dec23b75e2
--- /dev/null
+++ b/packages/db/prisma/migrations/20260506130119_default_treatment_strategy_to_mitigate/migration.sql
@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "Risk" ALTER COLUMN "treatmentStrategy" SET DEFAULT 'mitigate';
+
+-- AlterTable
+ALTER TABLE "Vendor" ALTER COLUMN "treatmentStrategy" SET DEFAULT 'mitigate';
diff --git a/packages/db/prisma/schema/risk.prisma b/packages/db/prisma/schema/risk.prisma
index fcb6c25a13..b4a95441dd 100644
--- a/packages/db/prisma/schema/risk.prisma
+++ b/packages/db/prisma/schema/risk.prisma
@@ -11,7 +11,11 @@ model Risk {
residualLikelihood Likelihood @default(very_unlikely)
residualImpact Impact @default(insignificant)
treatmentStrategyDescription String?
- treatmentStrategy RiskTreatmentType @default(accept)
+ // Mitigate is the workhorse — the AI mitigation generator writes a
+ // mitigation plan for every new risk, and the plan is stored under
+ // the active strategy. Defaulting to anything other than mitigate
+ // would put the AI plan under the wrong slot.
+ treatmentStrategy RiskTreatmentType @default(mitigate)
// Per-strategy text store. When the user switches strategies, the current
// `treatmentStrategyDescription` is moved into this map under the OLD
// strategy key, and the NEW strategy's saved value is loaded back into the
diff --git a/packages/db/prisma/schema/vendor.prisma b/packages/db/prisma/schema/vendor.prisma
index 5679053a8c..afdd196e3a 100644
--- a/packages/db/prisma/schema/vendor.prisma
+++ b/packages/db/prisma/schema/vendor.prisma
@@ -8,7 +8,9 @@ model Vendor {
inherentImpact Impact @default(insignificant)
residualProbability Likelihood @default(very_unlikely)
residualImpact Impact @default(insignificant)
- treatmentStrategy RiskTreatmentType @default(accept)
+ // See Risk.treatmentStrategy — default mitigate so AI plans land in
+ // the right slot.
+ treatmentStrategy RiskTreatmentType @default(mitigate)
treatmentStrategyDescription String?
// See `Risk.strategyDescriptions`.
strategyDescriptions Json?