diff --git a/app/api/focused-investigation/deconstruct/route.js b/app/api/focused-investigation/deconstruct/route.js
index 32a0cd4..e9ce041 100644
--- a/app/api/focused-investigation/deconstruct/route.js
+++ b/app/api/focused-investigation/deconstruct/route.js
@@ -50,6 +50,23 @@ async function post(request) {
);
}
+ const REQUEST_LENGTH_LIMITS = {
+ answer: 10000,
+ question: 2048,
+ centralStatement: 2048,
+ targetLabel: 2048,
+ targetDescription: 2048,
+ };
+
+ for (const [field, limit] of Object.entries(REQUEST_LENGTH_LIMITS)) {
+ if (body[field] && body[field].length > limit) {
+ return Response.json(
+ { error: `Request field "${field}" exceeds maximum length of ${limit} characters` },
+ { status: 400 },
+ );
+ }
+ }
+
const prompt = buildFocusedDeconstructPrompt({
targetLabel: body.targetLabel,
targetDescription: body.targetDescription,
diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx
index 9d4a0e7..647d83a 100644
--- a/components/reasoning-workspace.jsx
+++ b/components/reasoning-workspace.jsx
@@ -30,6 +30,9 @@ function isTechnicalSummary(summary) {
return false;
}
+// ── Focused answer contract ──────────────────────────────────
+const FOCUSED_ANSWER_MAX_LENGTH = 10000;
+
// ── Recovery state components (Phase 2) ───────────────────────
function ProviderUnavailableCard({ onRestart }) {
@@ -238,8 +241,11 @@ function FocusedQuestionBody({
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
)}
diff --git a/docs/current-handoff.md b/docs/current-handoff.md
index 98f762c..c62509a 100644
--- a/docs/current-handoff.md
+++ b/docs/current-handoff.md
@@ -125,6 +125,15 @@ Jenkins SCM branch used to load the Jenkinsfile is conceptually separate from th
- CSS Grid with responsive column placement replaces original flexbox; three DOM sections ensure correct mobile stacking without duplicating content
- desktop two-column presentation (explanation left / sign-in right) preserved unchanged at `md` breakpoint and above
+## Focused-investigation input hardening
+
+- Focused answer now has a visible 10,000-character UI limit (maxLength + character counter).
+- Server enforces matching 10,000-character bound on `/api/focused-investigation/deconstruct`.
+- Other prompt-bearing fields retain explicit defensive bounds (2048 characters each).
+- Oversized/malformed requests are rejected before reasoning with controlled HTTP 400.
+- No punctuation/HTML/SQL-style content stripping introduced.
+- Reasoning semantics unchanged.
+
## CURRENT MVP DIRECTION
Initial-decomposition hardening is frozen for the current MVP stage.
diff --git a/tests/focused-deconstruct-boundary.test.js b/tests/focused-deconstruct-boundary.test.js
index 9f87844..9fbea25 100644
--- a/tests/focused-deconstruct-boundary.test.js
+++ b/tests/focused-deconstruct-boundary.test.js
@@ -393,6 +393,174 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
}
});
+ it("rejects oversized focused answer with 400 and does not reach reasoning seam", async () => {
+ const generateReconstruction = vi.fn().mockResolvedValue({
+ response: {},
+ providerApiPath: "/api/chat",
+ });
+
+ vi.doMock("@/lib/llm/provider", () => ({
+ getProvider: () => ({ generateReconstruction }),
+ getProviderModelName: () => "configured-model",
+ }));
+
+ const largeAnswer = "x".repeat(10001);
+
+ const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
+
+ const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetNodeId: "node-id",
+ targetLabel: "label",
+ targetDescription: "description",
+ centralStatement: "central statement",
+ question: "question?",
+ answer: largeAnswer,
+ }),
+ }));
+
+ expect(response.status).toBe(400);
+ const json = await response.json();
+ expect(json.error).toMatch(/answer.*exceeds maximum length/i);
+ expect(generateReconstruction).not.toHaveBeenCalled();
+ });
+
+ it("accepts focused answer at exact server max (10000) and reaches reasoning seam", async () => {
+ const generateReconstruction = vi.fn().mockResolvedValue({
+ response: {
+ targetNodeId: "node-id",
+ observations: [],
+ uncertainties: [],
+ assumptions: [],
+ relationships: [],
+ possibleFollowUpQuestions: [],
+ },
+ providerApiPath: "/api/chat",
+ });
+
+ vi.doMock("@/lib/llm/provider", () => ({
+ getProvider: () => ({ generateReconstruction }),
+ getProviderModelName: () => "configured-model",
+ }));
+
+ const exactMaxAnswer = "x".repeat(10000);
+
+ const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
+
+ const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetNodeId: "node-id",
+ targetLabel: "label",
+ targetDescription: "description",
+ centralStatement: "central statement",
+ question: "question?",
+ answer: exactMaxAnswer,
+ }),
+ }));
+
+ expect(response.status).toBe(200);
+ const json = await response.json();
+ expect(json.success).toBe(true);
+ expect(generateReconstruction).toHaveBeenCalled();
+ });
+
+ it("rejects malformed required field (wrong type) with 400", async () => {
+ const generateReconstruction = vi.fn().mockResolvedValue({
+ response: {}, providerApiPath: "/api/chat",
+ });
+
+ vi.doMock("@/lib/llm/provider", () => ({
+ getProvider: () => ({ generateReconstruction }),
+ getProviderModelName: () => "configured-model",
+ }));
+
+ const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
+
+ const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetNodeId: ["not-a-string"],
+ targetLabel: "label",
+ targetDescription: "description",
+ centralStatement: "central statement",
+ question: "question?",
+ answer: "answer.",
+ }),
+ }));
+
+ expect(response.status).toBe(400);
+ const json = await response.json();
+ expect(json.error).toMatch(/targetNodeId.*string/i);
+ expect(generateReconstruction).not.toHaveBeenCalled();
+ });
+
+ it("rejects oversized targetDescription with 400", async () => {
+ const generateReconstruction = vi.fn().mockResolvedValue({
+ response: {}, providerApiPath: "/api/chat",
+ });
+
+ vi.doMock("@/lib/llm/provider", () => ({
+ getProvider: () => ({ generateReconstruction }),
+ getProviderModelName: () => "configured-model",
+ }));
+
+ const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
+
+ const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetNodeId: "node-id",
+ targetLabel: "label",
+ targetDescription: "x".repeat(2049),
+ centralStatement: "central statement",
+ question: "question?",
+ answer: "answer.",
+ }),
+ }));
+
+ expect(response.status).toBe(400);
+ const json = await response.json();
+ expect(json.error).toMatch(/targetDescription.*exceeds maximum length/i);
+ expect(generateReconstruction).not.toHaveBeenCalled();
+ });
+
+ it("rejects malformed centralStatement (number) with 400", async () => {
+ const generateReconstruction = vi.fn().mockResolvedValue({
+ response: {}, providerApiPath: "/api/chat",
+ });
+
+ vi.doMock("@/lib/llm/provider", () => ({
+ getProvider: () => ({ generateReconstruction }),
+ getProviderModelName: () => "configured-model",
+ }));
+
+ const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
+
+ const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ targetNodeId: "node-id",
+ targetLabel: "label",
+ targetDescription: "description",
+ centralStatement: 12345,
+ question: "question?",
+ answer: "answer.",
+ }),
+ }));
+
+ expect(response.status).toBe(400);
+ const json = await response.json();
+ expect(json.error).toMatch(/centralStatement.*string/i);
+ expect(generateReconstruction).not.toHaveBeenCalled();
+ });
+
it("returns a sanitized 503 when the provider is unavailable", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.doMock("@/lib/llm/provider", () => ({