From e734ed8a4854afc3cab942bfdbe24492d6f112ed Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 13:21:10 -0700 Subject: [PATCH] FN-8541: expose actionable validator diagnostics Surface normalized, secret-safe validator evidence and assertion verdicts across mission events and remediation work. - Normalize, redact, bound, and render per-assertion validation diagnostics in shared core APIs. - Attach verdict-driven diagnostics to failure events and generated fixes while suppressing duplicate triage noise. - Display validator evidence in Mission activity and document the operator workflow. - Add core and engine coverage for evidence, mixed blocked verdicts, and triage behavior. Files changed: .changeset/fn-8541-validator-diagnostics.md | 7 ++ docs/missions.md | 17 +++ .../mission-store.validation-diagnostics.test.ts | 65 ++++++++++ packages/core/src/async-mission-store.ts | 8 +- packages/core/src/index.gate.ts | 10 ++ packages/core/src/index.ts | 10 ++ packages/core/src/mission-store.ts | 10 +- packages/core/src/mission-types.ts | 136 +++++++++++++++++++++ .../dashboard/app/components/MissionManager.css | 21 ++++ .../dashboard/app/components/MissionManager.tsx | 56 +++++++++ packages/dashboard/app/components/mission-types.ts | 3 + .../src/__tests__/mission-execution-loop.test.ts | 44 +++++++ .../mission-validator-behavioral-posture.test.ts | 37 +++++- packages/engine/src/mission-execution-loop.ts | 117 +++++++++++++----- 14 files changed, 501 insertions(+), 40 deletions(-) Fusion-Task-Id: FN-8541 Fusion-Task-Lineage: b226fc34-f35c-4ce5-bcb5-e418529caaa7 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8541-validator-diagnostics.md | 7 + docs/missions.md | 17 +++ ...ssion-store.validation-diagnostics.test.ts | 65 +++++++++ packages/core/src/async-mission-store.ts | 8 +- packages/core/src/index.gate.ts | 10 ++ packages/core/src/index.ts | 10 ++ packages/core/src/mission-store.ts | 10 +- packages/core/src/mission-types.ts | 136 ++++++++++++++++++ .../app/components/MissionManager.css | 21 +++ .../app/components/MissionManager.tsx | 56 ++++++++ .../dashboard/app/components/mission-types.ts | 3 + .../__tests__/mission-execution-loop.test.ts | 44 ++++++ ...ssion-validator-behavioral-posture.test.ts | 37 ++++- packages/engine/src/mission-execution-loop.ts | 115 +++++++++++---- 14 files changed, 500 insertions(+), 39 deletions(-) create mode 100644 .changeset/fn-8541-validator-diagnostics.md create mode 100644 packages/core/src/__tests__/mission-store.validation-diagnostics.test.ts diff --git a/.changeset/fn-8541-validator-diagnostics.md b/.changeset/fn-8541-validator-diagnostics.md new file mode 100644 index 0000000000..aebefa3604 --- /dev/null +++ b/.changeset/fn-8541-validator-diagnostics.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show failed mission assertions and safe validator evidence in remediation work. +category: fix +dev: Validation diagnostics are normalized consistently across SQLite and PostgreSQL mission stores. diff --git a/docs/missions.md b/docs/missions.md index 84526dbc34..dd50e20f58 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -657,6 +657,23 @@ interface MissionAssertionFailureRecord { **Full state snapshots:** `MissionFeatureLoopSnapshot` captures complete loop state including all validator runs and lineage chains for post-mortem analysis. +### Validation failure diagnostics + +A `validation_failed` Mission activity event includes `metadata.validationDiagnostics`, the typed source of truth for failure reporting. It contains the validator `runId`, `sourceFeatureId`, overall outcome, next action, and ordered per-assertion verdicts with expected, observed, message, and evidence references. The visible event text is derived from this objectβ€”not an AI summaryβ€”so a failed event always names failed assertion IDs and labels any separately blocked assertion IDs as blocked (never as failed). + +Evidence is secret-redacted before persistence. Each assertion retains at most 16 evidence entries and every message, expected, observed, and evidence text field is capped at 4,096 UTF-8 bytes. Bounded fields carry `truncated: true`, excess evidence is reported as `omittedEvidenceCount`, project paths become project-relative, and external or disposable absolute paths become `[external path omitted]`. + +Generated fix features and their triaged tasks include the same **Validation cause** section with source feature, validator run, failed assertion IDs, bounded observations, and evidence. SQLite `MissionStore` and PostgreSQL `AsyncMissionStore` use the shared renderer, so a retry does not produce backend-specific causes or duplicate sections. A fix that is already linked to a canonical task is an idempotent race; otherwise Mission activity tells the operator to inspect and retry triage rather than exposing internal exception/loop-state prose. + +The loop state is internal scheduling context, not an operator diagnosis. Its public meanings and actions are: + +| Public state | Meaning | Operator action | +|---|---|---| +| validating | A validator run is evaluating the landed implementation. | Inspect the run only if it remains active beyond the stale-run window. | +| needs_fix | A validator found a remediable assertion failure. | Review the event’s Validation diagnostics and triage the generated Fix feature/task. | +| blocked | Validation could not obtain sufficient proof, or retry budget is exhausted. | Resolve the stated external constraint or root cause, then retry/triage the feature. | +| implementing | A task is carrying out the feature or its generated remediation. | Follow the linked task; duplicate validator triggers with a canonical task are ignored. | + ### Operator Troubleshooting | Symptom | Diagnosis | Resolution | diff --git a/packages/core/src/__tests__/mission-store.validation-diagnostics.test.ts b/packages/core/src/__tests__/mission-store.validation-diagnostics.test.ts new file mode 100644 index 0000000000..80d0435733 --- /dev/null +++ b/packages/core/src/__tests__/mission-store.validation-diagnostics.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeValidationDiagnostics, + renderValidationCause, + renderValidationFailureDescription, + VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION, + VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES, +} from "../mission-types.js"; + +describe("validation diagnostics normalization", () => { + it("preserves order, limits evidence, and records omissions", () => { + const result = normalizeValidationDiagnostics({ + runId: "VR-1", sourceFeatureId: "F-1", outcome: "fail", + assertions: [{ assertionId: "CA-1", passed: false, evidence: Array.from({ length: 17 }, (_, index) => ({ text: `evidence-${index}` })) }], + }); + expect(result.assertions[0].evidence).toHaveLength(VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION); + expect(result.assertions[0].evidence[0].text).toBe("evidence-0"); + expect(result.assertions[0].omittedEvidenceCount).toBe(1); + }); + + it("redacts before safely truncating multibyte evidence and fields", () => { + const result = normalizeValidationDiagnostics({ + runId: "VR-1", sourceFeatureId: "F-1", outcome: "fail", + projectRoot: "/repo", + assertions: [{ assertionId: "CA-1", passed: false, message: `token=secret-value ${"πŸ˜€".repeat(2000)}`, expected: "/repo/src/example.ts", actual: "/private/tmp/secret.txt", evidence: [{ text: "Authorization: Bearer sk-live-ABCDEFG1234567890abcdef" }] }], + }); + const assertion = result.assertions[0]; + expect(assertion.message).toContain("[REDACTED]"); + expect(Buffer.byteLength(assertion.message!, "utf8")).toBeLessThanOrEqual(VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES); + expect(assertion.expected).toBe("src/example.ts"); + expect(assertion.actual).toBe("[external path omitted]"); + expect(assertion.evidence[0].text).toContain("[REDACTED]"); + expect(assertion.message).toContain("… [truncated]"); + }); + + it("omits Windows absolute paths outside the project root", () => { + const result = normalizeValidationDiagnostics({ + runId: "VR-1", sourceFeatureId: "F-1", outcome: "fail", + projectRoot: "C:\\repo", + assertions: [{ assertionId: "CA-1", passed: false, evidence: [{ text: "C:\\external\\secret.txt" }, { text: "C:\\repo\\src\\proof.test.ts" }] }], + }); + expect(result.assertions[0].evidence.map((entry) => entry.text)).toEqual(["[external path omitted]", "src/proof.test.ts"]); + }); + + it("renders outcome-consistent event and remediation prose", () => { + const diagnostics = normalizeValidationDiagnostics({ + runId: "VR-1", sourceFeatureId: "F-1", outcome: "fail", + assertions: [ + { assertionId: "CA-pass", verdict: "pass", passed: true }, + { assertionId: "CA-fail", verdict: "fail", passed: false, expected: "green", actual: "red" }, + { assertionId: "CA-blocked", verdict: "blocked", passed: false, message: "Service unavailable" }, + ], + }); + const eventDescription = renderValidationFailureDescription(diagnostics); + expect(eventDescription).toContain("CA-fail"); + expect(eventDescription).toContain("CA-blocked"); + expect(eventDescription).toContain("1 assertion failed"); + expect(eventDescription).toContain("1 assertion is blocked"); + expect(eventDescription).not.toContain("CA-pass"); + const remediationCause = renderValidationCause(diagnostics); + expect(remediationCause).toContain("Validator run: VR-1"); + expect(remediationCause).toContain("Blocked assertions: CA-blocked"); + expect(remediationCause).toContain("### CA-blocked (blocked)"); + }); +}); diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index 0f96eb9c1b..43e82e7405 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -9,7 +9,7 @@ import { EventEmitter } from "node:events"; import { and, eq, inArray, sql } from "drizzle-orm"; import * as schema from "./postgres/schema/index.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; -import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType } from "./mission-types.js"; +import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, renderValidationCause } from "./mission-types.js"; import type { Mission, Milestone, @@ -39,6 +39,7 @@ import type { ContractAssertionCreateInput, ContractAssertionUpdateInput, FeatureLoopState, + ValidationDiagnostics, } from "./mission-types.js"; import type { Goal } from "./goal-types.js"; import { @@ -1201,12 +1202,15 @@ export class AsyncMissionStore extends EventEmitter { failedAssertionIds: string[], failureReason?: string, title?: string, + diagnostics?: ValidationDiagnostics, ): Promise { const run = await getValidatorRun(this.db, runId); if (!run) throw new Error(`Validator run ${runId} not found`); if (run.featureId !== sourceFeatureId) throw new Error(`Validator run ${runId} belongs to feature ${run.featureId}, expected ${sourceFeatureId}`); const now = new Date().toISOString(); const reasonText = failureReason?.trim(); + // FNXC:MissionValidationDiagnostics 2026-07-23-12:00: PostgreSQL remediation uses the identical shared cause renderer as SQLite to prevent backend-specific operator diagnostics. + const causeText = diagnostics ? renderValidationCause(diagnostics) : undefined; /* FNXC:MissionFixIdempotency 2026-07-14-18:45: Generated remediation is one source/run operation. Lock the source feature, re-check lineage/open fixes under that lock, and increment the retry counter in the same transaction so concurrent validator workers cannot create duplicates or consume two attempts. @@ -1244,7 +1248,7 @@ export class AsyncMissionStore extends EventEmitter { id: this.generateId("F"), sliceId: source.sliceId, title: title ?? `Fix: ${source.title}`, - description: reasonText ? `${source.description ? `${source.description}\n\n` : ""}## Verification failure detail\n${reasonText}` : source.description, + description: [source.description, causeText ?? (reasonText ? `## Verification failure detail\n${reasonText}` : undefined)].filter(Boolean).join("\n\n") || undefined, acceptanceCriteria: source.acceptanceCriteria, status: "defined", createdAt: now, diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 03d790ea7a..1a3e318bfc 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1526,6 +1526,11 @@ export { SLICE_PLAN_STATES, FEATURE_LOOP_STATES, VALIDATOR_RUN_STATUSES, + VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION, + VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES, + normalizeValidationDiagnostics, + renderValidationFailureDescription, + renderValidationCause, MISSION_ASSERTION_STATUSES, MISSION_ASSERTION_TYPES, DEFAULT_MISSION_ASSERTION_TYPE, @@ -1542,6 +1547,11 @@ export type { SlicePlanState, FeatureLoopState, ValidatorRunStatus, + ValidationAssertionVerdict, + ValidationEvidenceReference, + ValidationAssertionDiagnostic, + ValidationDiagnostics, + ValidationDiagnosticsInput, MissionEventType, AutopilotStatus, Mission, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 35a600f4bc..cea662d9d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1665,6 +1665,11 @@ export { SLICE_PLAN_STATES, FEATURE_LOOP_STATES, VALIDATOR_RUN_STATUSES, + VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION, + VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES, + normalizeValidationDiagnostics, + renderValidationFailureDescription, + renderValidationCause, MISSION_ASSERTION_STATUSES, MISSION_ASSERTION_TYPES, DEFAULT_MISSION_ASSERTION_TYPE, @@ -1681,6 +1686,11 @@ export type { SlicePlanState, FeatureLoopState, ValidatorRunStatus, + ValidationAssertionVerdict, + ValidationEvidenceReference, + ValidationAssertionDiagnostic, + ValidationDiagnostics, + ValidationDiagnosticsInput, MissionEventType, AutopilotStatus, Mission, diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index b99e2be9b7..8b5640e010 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -14,7 +14,7 @@ import { EventEmitter } from "node:events"; import type { Database } from "./db.js"; import { fromJson, toJson, toJsonNullable } from "./db.js"; -import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType } from "./mission-types.js"; +import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, renderValidationCause } from "./mission-types.js"; import type { Goal, GoalStatus } from "./goal-types.js"; import type { Mission, @@ -51,6 +51,7 @@ import type { MilestoneValidationState, ValidatorRunStatus, FeatureLoopState, + ValidationDiagnostics, } from "./mission-types.js"; import { reconcileDeterministicDuplicate, runDeterministicDuplicateGuard } from "./duplicate-guard.js"; import { resolveEntryPointBranchAssignment } from "./branch-assignment.js"; @@ -2978,6 +2979,7 @@ export class MissionStore extends EventEmitter { failedAssertionIds: string[], failureReason?: string, title?: string, + diagnostics?: ValidationDiagnostics, ): MissionFeature { const sourceFeature = this.getFeature(sourceFeatureId); if (!sourceFeature) { @@ -3039,9 +3041,9 @@ export class MissionStore extends EventEmitter { // R6 β€” surface the observed-vs-expected reason to the remediation agent. const reasonText = failureReason?.trim(); - const fixDescription = reasonText - ? `${sourceFeature.description ? `${sourceFeature.description}\n\n` : ""}## Verification failure detail\n${reasonText}` - : sourceFeature.description; + // FNXC:MissionValidationDiagnostics 2026-07-23-12:00: Generated remediation carries the same normalized cause as its event so operators and executors never need to reconstruct a validator failure. + const causeText = diagnostics ? renderValidationCause(diagnostics) : undefined; + const fixDescription = [sourceFeature.description, causeText ?? (reasonText ? `## Verification failure detail\n${reasonText}` : undefined)].filter(Boolean).join("\n\n") || undefined; const fixFeature: MissionFeature = { id: fixFeatureId, diff --git a/packages/core/src/mission-types.ts b/packages/core/src/mission-types.ts index c2421be628..1cd8aa0c41 100644 --- a/packages/core/src/mission-types.ts +++ b/packages/core/src/mission-types.ts @@ -10,6 +10,7 @@ */ import type { Goal } from "./goal-types.js"; +import { redactSecrets } from "./redact-secrets.js"; // ── Status Enums ───────────────────────────────────────────────────── @@ -54,6 +55,141 @@ export const FEATURE_LOOP_TRANSITIONS: Readonly; + }>; + projectRoot?: string; +} + +function boundValidationText(value: unknown, projectRoot?: string): { value?: string; truncated?: boolean } { + if (typeof value !== "string") return {}; + let text = redactSecrets(value); + // Paths from the project are useful evidence; disposable/external paths are not. + text = text.replace(/(?:[A-Za-z]:\\|\/)[^\s'"`]+/g, (path) => { + const normalizedRoot = projectRoot?.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedPath = path.replace(/\\/g, "/"); + return normalizedRoot && (normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`)) + ? normalizedPath.slice(normalizedRoot.length).replace(/^\//, "") || "." + : "[external path omitted]"; + }); + const bytes = Buffer.byteLength(text, "utf8"); + if (bytes <= VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES) return { value: text }; + const marker = "… [truncated]"; + const limit = VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES - Buffer.byteLength(marker, "utf8"); + let end = 0; + let used = 0; + for (const character of text) { + const size = Buffer.byteLength(character, "utf8"); + if (used + size > limit) break; + used += size; + end += character.length; + } + return { value: `${text.slice(0, end)}${marker}`, truncated: true }; +} + +/** Normalize and redact validation evidence before any mission artifact persists it. */ +export function normalizeValidationDiagnostics(input: ValidationDiagnosticsInput): ValidationDiagnostics { + return { + runId: input.runId, + sourceFeatureId: input.sourceFeatureId, + outcome: input.outcome, + nextAction: input.outcome === "fail" ? "Review the failed assertions and triage the generated fix work." : "Review the validator run and retry or triage the feature when ready.", + assertions: input.assertions.map((assertion) => { + const evidence = (assertion.evidence ?? []).slice(0, VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION).map((item) => { + const bounded = boundValidationText(item.text, input.projectRoot); + return { ...(typeof item.kind === "string" ? { kind: item.kind } : {}), ...(bounded.value !== undefined ? { text: bounded.value } : {}), ...(bounded.truncated ? { truncated: true } : {}) }; + }); + const message = boundValidationText(assertion.message, input.projectRoot); + const expected = boundValidationText(assertion.expected, input.projectRoot); + const actual = boundValidationText(assertion.actual, input.projectRoot); + return { + assertionId: assertion.assertionId, + verdict: assertion.verdict ?? (assertion.passed ? "pass" : "fail"), + ...(message.value !== undefined ? { message: message.value } : {}), + ...(expected.value !== undefined ? { expected: expected.value } : {}), + ...(actual.value !== undefined ? { actual: actual.value } : {}), + evidence, + ...((assertion.evidence?.length ?? 0) > evidence.length ? { omittedEvidenceCount: (assertion.evidence?.length ?? 0) - evidence.length } : {}), + }; + }), + }; +} + +/** Render failure prose from normalized data only; never from non-authoritative judge summaries. */ +export function renderValidationFailureDescription(diagnostics: ValidationDiagnostics): string { + const failed = diagnostics.assertions.filter((assertion) => assertion.verdict === "fail"); + const blocked = diagnostics.assertions.filter((assertion) => assertion.verdict === "blocked"); + const failedText = `${failed.length} assertion${failed.length === 1 ? "" : "s"} failed (${failed.map((assertion) => assertion.assertionId).join(", ") || "no assertion identity"})`; + const blockedText = blocked.length > 0 + ? `; ${blocked.length} assertion${blocked.length === 1 ? " is" : "s are"} blocked (${blocked.map((assertion) => assertion.assertionId).join(", ")})` + : ""; + return `Validation failed for feature ${diagnostics.sourceFeatureId}: ${failedText}${blockedText}. ${diagnostics.nextAction}`; +} + +/** Stable remediation context used by both store implementations and their task descriptions. */ +export function renderValidationCause(diagnostics: ValidationDiagnostics): string { + const nonPassing = diagnostics.assertions.filter((assertion) => assertion.verdict !== "pass"); + const failed = nonPassing.filter((assertion) => assertion.verdict === "fail"); + const blocked = nonPassing.filter((assertion) => assertion.verdict === "blocked"); + const lines = [ + "## Validation cause", + `Source feature: ${diagnostics.sourceFeatureId}`, + `Validator run: ${diagnostics.runId}`, + `Failed assertions: ${failed.map((assertion) => assertion.assertionId).join(", ") || "none recorded"}`, + ...(blocked.length > 0 ? [`Blocked assertions: ${blocked.map((assertion) => assertion.assertionId).join(", ")}`] : []), + ]; + for (const assertion of nonPassing) { + lines.push(`### ${assertion.assertionId} (${assertion.verdict})`, ...(assertion.expected ? [`Expected: ${assertion.expected}`] : []), ...(assertion.actual ? [`Observed: ${assertion.actual}`] : []), ...(assertion.message ? [`Details: ${assertion.message}`] : []), ...assertion.evidence.map((evidence) => `Evidence: ${evidence.text ?? evidence.kind ?? "recorded"}${evidence.truncated ? " (truncated)" : ""}`), ...(assertion.omittedEvidenceCount ? [`Additional evidence omitted: ${assertion.omittedEvidenceCount}`] : [])); + } + return lines.join("\n"); +} + /** Interview state for AI-assisted specification */ export const INTERVIEW_STATES = ["not_started", "in_progress", "completed", "needs_update"] as const; export type InterviewState = (typeof INTERVIEW_STATES)[number]; diff --git a/packages/dashboard/app/components/MissionManager.css b/packages/dashboard/app/components/MissionManager.css index 63a624a435..c9c9b17ff7 100644 --- a/packages/dashboard/app/components/MissionManager.css +++ b/packages/dashboard/app/components/MissionManager.css @@ -1461,6 +1461,27 @@ Narrow/mobile Missions puts Plan New Mission at the bottom of the list, using th color: var(--text-dim); } +.mission-event__diagnostics, +.mission-event__diagnostic { + display: flex; + flex-direction: column; + gap: var(--space-xs); + overflow-wrap: anywhere; +} + +.mission-event__diagnostics { + padding: var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-muted); +} + +.mission-event__diagnostic + .mission-event__diagnostic { + padding-top: var(--space-sm); + border-top: 1px solid var(--border); +} + .mission-event__metadata { display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index da4354fc3c..0e590060f9 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -55,6 +55,7 @@ import type { MilestoneValidationTelemetry, MissionFeatureLoopSnapshot, MissionValidatorRun, + ValidationDiagnostics, } from "./mission-types"; import { fetchMissions, @@ -545,6 +546,46 @@ function matchesEventFilter( } } +function getValidationDiagnostics(metadata: Record | null): ValidationDiagnostics | undefined { + const candidate = metadata?.validationDiagnostics; + if (!candidate || typeof candidate !== "object") return undefined; + const diagnostics = candidate as Partial; + if (typeof diagnostics.runId !== "string" || typeof diagnostics.sourceFeatureId !== "string" || !Array.isArray(diagnostics.assertions)) { + return undefined; + } + + // FNXC:MissionValidationDiagnostics 2026-07-23-13:15: Mission events are + // durable and can predate this contract. Normalize partial JSON at the UI + // boundary so malformed legacy metadata cannot crash activity on any viewport. + return { + runId: diagnostics.runId, + sourceFeatureId: diagnostics.sourceFeatureId, + outcome: diagnostics.outcome === "pass" || diagnostics.outcome === "fail" || diagnostics.outcome === "blocked" || diagnostics.outcome === "error" || diagnostics.outcome === "inconclusive" ? diagnostics.outcome : "error", + nextAction: typeof diagnostics.nextAction === "string" ? diagnostics.nextAction : "Review the validator run before retrying or triaging the feature.", + assertions: diagnostics.assertions.map((value, index) => { + const assertion = value && typeof value === "object" ? value as Partial : {}; + const evidence = Array.isArray(assertion.evidence) ? assertion.evidence.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const reference = item as { kind?: unknown; text?: unknown; truncated?: unknown }; + return [{ + ...(typeof reference.kind === "string" ? { kind: reference.kind } : {}), + ...(typeof reference.text === "string" ? { text: reference.text } : {}), + ...(reference.truncated === true ? { truncated: true } : {}), + }]; + }) : []; + return { + assertionId: typeof assertion.assertionId === "string" ? assertion.assertionId : `Assertion ${index + 1}`, + verdict: assertion.verdict === "pass" || assertion.verdict === "fail" || assertion.verdict === "blocked" ? assertion.verdict : "blocked", + ...(typeof assertion.message === "string" ? { message: assertion.message } : {}), + ...(typeof assertion.expected === "string" ? { expected: assertion.expected } : {}), + ...(typeof assertion.actual === "string" ? { actual: assertion.actual } : {}), + evidence, + ...(typeof assertion.omittedEvidenceCount === "number" && assertion.omittedEvidenceCount > 0 ? { omittedEvidenceCount: assertion.omittedEvidenceCount } : {}), + }; + }), + }; +} + function getEventTypeClassName(eventType: MissionEventType): string { if (eventType === "error" || eventType === "warning") { return "mission-event__type--error"; @@ -4260,6 +4301,21 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr {new Date(event.timestamp).toLocaleString()} + {(() => { + const diagnostics = getValidationDiagnostics(event.metadata); + if (!diagnostics) return null; + return
+ {t("missions.validatorRun", "Validator run")}: {diagnostics.runId} + {t("missions.nextAction", "Next action")}: {diagnostics.nextAction} + {diagnostics.assertions.map((assertion) =>
+ {assertion.assertionId}: {assertion.verdict} + {assertion.expected && {t("missions.expected", "Expected")}: {assertion.expected}} + {assertion.actual && {t("missions.observed", "Observed")}: {assertion.actual}} + {assertion.evidence.map((evidence, index) => {t("missions.evidence", "Evidence")}: {evidence.text ?? evidence.kind ?? t("missions.recorded", "recorded")}{evidence.truncated ? ` (${t("missions.truncated", "truncated")})` : ""})} + {assertion.omittedEvidenceCount ? {t("missions.evidenceOmitted", "Additional evidence omitted")}: {assertion.omittedEvidenceCount} : null} +
)} +
; + })()} {hasMetadata && (