diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv index 14e495a9b5..0af71f3d08 100644 --- a/docs/user-visible-errors.csv +++ b/docs/user-visible-errors.csv @@ -12,7 +12,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement 11,src/domain/errors.ts:ArtifactOperationError,Artifact inventory or extraction fails,"Artifact {operation} failed: {reason} at {logicalPath} (declared_sha256=...)",Exposes paths hashes and internal reason,"Reason-specific plain message with safe recovery step.",implemented,focused assertions passed; full `npm run check` passed 12,src/domain/errors.ts:EvidenceFileError,Evidence file operation fails,"Evidence bundle {operation} failed: {reason}",Internal reason token gives weak recovery,"Reason-specific plain message with safe recovery step.",implemented,focused assertions passed; full `npm run check` passed 13,src/domain/errors.ts:EvidenceIntegrityError,Evidence validation fails,Raw constructor message,May expose validation internals and lacks recovery,"Evidence is invalid or has changed. Recreate or re-import it, then try again.",implemented,focused assertions passed; full `npm run check` passed -14,src/domain/errors.ts:UnknownRegistryError,Evidence-state mutation fails,"Residual unknown registry mutation failed: {reason}",Exposes internal subsystem and state token,"Evidence state changed before the update completed. Refresh the current state and try again.",implemented,focused assertions passed; full `npm run check` passed +14,src/domain/errors.ts:UnknownRegistryError,Evidence-state mutation fails,"Residual unknown registry mutation failed: {reason}",Exposes internal subsystem and state token,"Missing IDs identify the session and retry; concurrent revision conflicts refresh current state.",implemented,focused assertions passed; full `npm run check` passed 15,src/domain/errors.ts:ProcessCaptureError,Process capture fails,Raw constructor message,May expose process and cleanup internals,"Process capture could not complete. Check command access and limits, then try again.",implemented,focused assertions passed; full `npm run check` passed 16,src/domain/errors.ts:AnalysisTimeoutError,Analysis deadline expires,"Analysis operation timed out after {timeoutMs}ms: {operation}",Exposes internal deadline and operation; weak recovery,"Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.",implemented,focused assertions passed; full `npm run check` passed 17,src/domain/errors.ts:HopperTimeoutError,Provider deadline expires,"Hopper did not respond within {timeoutMs}ms",Exposes provider and internal deadline,"Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.",implemented,focused assertions passed; full `npm run check` passed diff --git a/src/application/ArtifactInventory/scanCanonical.ts b/src/application/ArtifactInventory/scanCanonical.ts index 78e47e400b..9e4bbe2fc5 100644 --- a/src/application/ArtifactInventory/scanCanonical.ts +++ b/src/application/ArtifactInventory/scanCanonical.ts @@ -49,7 +49,7 @@ export const scanCanonicalArtifactInventory = async ( (!integrity.approved || !integrity.enabled) ) throw new ArtifactReaderFailure( - "unavailable", + "policy", "Integrity continuation requires explicit approval and operator policy", ); const metadata = await lstat(path); diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index ff85c614fc..07fddeb829 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -50,6 +50,7 @@ export class ArtifactReaderFailure extends Error { | "integrity" | "limit" | "path" + | "policy" | "unavailable", message: string, options?: ErrorOptions, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 824aba1f28..4da6004a68 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -24,6 +24,8 @@ export const analysisErrorRemediationAction = ( ): string => { if (error instanceof AnalysisInputError) return "Correct the listed arguments and retry."; + if (error instanceof UnknownRegistryError && error.reason === "not-found") + return "Check that the unknown_id belongs to this session, then retry."; if (error instanceof ReplayPlanStaleError) return "Review the rebuilt replay plan and explicitly approve its new digest."; if (error instanceof PermissionRequiredError) @@ -99,7 +101,7 @@ const artifactErrorCategory = ( if (reason === "integrity") return "integrity_mismatch"; if (reason === "limit") return "truncated"; if (reason === "cancelled") return "cancelled"; - if (reason === "unavailable") return "unavailable"; + if (reason === "policy" || reason === "unavailable") return "unavailable"; return "execution_failure"; }; @@ -139,6 +141,8 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { return evidenceFileMessage(error.reason); if (error instanceof InvestigationWorkspaceError) return investigationWorkspaceMessage(error.reason); + if (error instanceof UnknownRegistryError && error.reason === "not-found") + return "The requested residual unknown does not exist in this session. Check the unknown_id and try again."; if (error instanceof UnknownRegistryError) return "Evidence state changed before the update completed. Refresh the current state and try again."; if (error instanceof ConfigurationError) @@ -203,8 +207,10 @@ const artifactMessage = (reason: ArtifactOperationError["reason"]): string => { return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact."; if (reason === "path") return "Artifact path is not allowed. Choose a path inside the artifact and try again."; + if (reason === "policy") + return "Artifact integrity continuation is disabled by policy. Configure REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED=true and retry only if continuing after mismatches is approved."; if (reason === "unavailable") - return "Artifact format is not available on this system. Choose another artifact or supported environment."; + return "Artifact processing is unavailable for the current target or policy. Check artifact support and required approvals."; if (reason === "format" || reason === "integrity") return "Artifact is invalid or has changed. Get a fresh copy and try again."; return "Artifact could not be read or written. Check file access and try again."; diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index a84cb6a8f3..5fcb7c0ed5 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -220,6 +220,7 @@ const requestErrorDetails = ( issues: error.issues.map((issue) => ({ path: [...issue.path], reason: issue.reason, + ...(issue.message === undefined ? {} : { message: issue.message }), ...(issue.expected === undefined ? {} : { expected: issue.expected }), ...(issue.minimum === undefined ? {} : { minimum: issue.minimum }), ...(issue.maximum === undefined ? {} : { maximum: issue.maximum }), diff --git a/src/domain/errors.ts b/src/domain/errors.ts index cafdb2479b..80a2cb5266 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -97,6 +97,8 @@ export interface AnalysisInputIssue { | "out_of_range" | "invalid_value" | "invalid_format"; + /** Schema-authored correction guidance for cross-field or custom checks. */ + readonly message?: string; readonly expected?: JsonValue; readonly minimum?: number; readonly maximum?: number; @@ -293,6 +295,7 @@ export class ArtifactOperationError extends AnalysisError { | "integrity" | "limit" | "path" + | "policy" | "unavailable" | "io", readonly artifactDetails?: Readonly<{ diff --git a/src/domain/inputIssueProjection.ts b/src/domain/inputIssueProjection.ts index af384ce696..23e00223b5 100644 --- a/src/domain/inputIssueProjection.ts +++ b/src/domain/inputIssueProjection.ts @@ -46,7 +46,13 @@ const projectIssue = ( expected: issue.values.filter(isSafeExpected), }, ]; - return [{ path, reason: "invalid_value" }]; + return [ + { + path, + reason: "invalid_value", + ...(issue.message.length > 0 ? { message: issue.message } : {}), + }, + ]; }; const valueAtPath = ( diff --git a/tests/errors.test.ts b/tests/errors.test.ts index f90b138a94..ea26e66107 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -242,6 +242,61 @@ describe("analysis error projection", () => { ).toEqual([]); }); + it("gives missing residual unknowns a lookup-specific remediation", () => { + expect( + projectAnalysisError(new UnknownRegistryError("not-found")), + ).toMatchObject({ + code: "execution_failure", + message: + "The requested residual unknown does not exist in this session. Check the unknown_id and try again.", + remediation: { + action: + "Check that the unknown_id belongs to this session, then retry.", + }, + details: { reason: "not-found" }, + }); + }); + + it("retains custom input guidance in the projected error details", () => { + expect( + projectAnalysisError( + new AnalysisInputError("find_changed_behavior", undefined, [ + { + path: [], + reason: "invalid_value", + message: + "Supply either existing comparisons or one investigation_run", + }, + ]), + ), + ).toMatchObject({ + details: { + issues: [ + { + path: [], + reason: "invalid_value", + message: + "Supply either existing comparisons or one investigation_run", + }, + ], + }, + }); + }); + + it("distinguishes disabled integrity continuation from format support", () => { + expect( + projectAnalysisError( + new ArtifactOperationError("inventory_artifact", "policy"), + ), + ).toMatchObject({ + code: "artifact_operation_failed", + category: "unavailable", + message: expect.stringContaining( + "REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED=true", + ), + }); + }); + it("validates every closed error-reason variant against the generated contract", () => { const variants: AnalysisError[] = [ ...( @@ -251,6 +306,7 @@ describe("analysis error projection", () => { "integrity", "limit", "path", + "policy", "unavailable", "io", ] as const @@ -313,7 +369,7 @@ describe("analysis error projection", () => { ).map((diagnostic) => new HopperRemoteError(9, "safe", diagnostic)), ]; - expect(variants).toHaveLength(38); + expect(variants).toHaveLength(39); for (const variant of variants) { const projected = projectAnalysisError(variant); const parsed = analysisErrorProjectionSchema.safeParse(projected); diff --git a/tests/inputIssueProjection.test.ts b/tests/inputIssueProjection.test.ts new file mode 100644 index 0000000000..7a6727615f --- /dev/null +++ b/tests/inputIssueProjection.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { artifactExtractionInputSchema } from "../src/contracts/artifactToolContracts.js"; +import { projectInputIssues } from "../src/domain/inputIssueProjection.js"; + +describe("input issue projection", () => { + it("preserves schema-authored custom correction guidance", () => { + const schema = z + .object({ left: z.string().optional(), right: z.string().optional() }) + .superRefine((value, context) => { + if (value.left === undefined && value.right === undefined) + context.addIssue({ + code: "custom", + path: [], + message: "Supply either left or right", + }); + }); + const parsed = schema.safeParse({}); + if (parsed.success) throw new Error("expected invalid input"); + + expect(projectInputIssues(parsed.error.issues, {})).toEqual([ + { + path: [], + reason: "invalid_value", + message: "Supply either left or right", + }, + ]); + }); + + it("rejects relative extraction destinations at the caller boundary", () => { + expect( + artifactExtractionInputSchema.safeParse({ + approved: true, + output_root: "relative/output", + occurrence_ids: [`occ_${"0".repeat(64)}`], + }).success, + ).toBe(false); + }); +});