From eb5ad4c920b1f04959caaab12c7c5f963b745edd Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:36:08 -0600 Subject: [PATCH] fix(cases): say a Cases-disabled denial is final and name the Experimental setting (#20892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Cases is an experimental feature; an instance admin turns it on in Experimental settings > - When Cases is off, every case and workflow route answers 403 "Cases are disabled" with no other information > - A chat agent asked to "open a customer case" could not tell whether the denial was final or what to offer > - In one eval run it created a project and a task in place of the case; in another it spent most of its turn searching routes before it replied > - This pull request gives the denial a code and a final, actionable remediation, like the board-only denial > - The benefit is a fast, truthful reply that names the setting and lets the person choose ## Linked Issues or Issue Description **What happened** In the chat eval cases.cases-disabled ("Open a customer case for this bug." with Cases off), the agent got 403 "Cases are disabled". In one run it then created a project and a bug task without asking. In a later run it searched many routes and instance settings for about 100 seconds before it replied. **Expected behavior** The denial says that Cases is off, that the denial is final, where an instance admin turns it on, and that the agent must not create a substitute record before the person chooses. The agent replies at once and offers the setting or an ordinary task. **Steps to reproduce** 1. Turn off Cases in Experimental settings. 2. As an agent, call GET /api/companies/{companyId}/cases. 3. See a bare 403 "Cases are disabled" with no code or remediation. ## What Changed - Rebased on #20894, which already gave the case routes a `cases_disabled` code, a final remediation and the hidden-settings check. This PR moves that remediation into one helper used by every case and workflow path, and adds: no substitute task or project before the person chooses, and the offer of an ordinary task. - New `casesDisabledError()` in `server/src/services/cases-disabled.ts`: same 403 message, plus `code: "cases_disabled"` and a remediation. The remediation says the denial is final, forbids a substitute task or project before the person chooses, links `[Experimental settings](//company/settings/instance/experimental)`, and offers an ordinary task. - Native tool results carry only the error message, so the native case tools, workflow tools and runner tool authority inline the remediation in the message (`casesDisabledError({ inlineRemediation: true })`). - Case routes, workflow service, case handoffs, native case tools, native workflow tools and the runner tool authority use the helper instead of a bare `forbidden("Cases are disabled")`. - Tests: `cases-routes.test.ts` checks the code and remediation; `native-case-tools.integration.test.ts` checks the native message carries the remediation (red on master, green here). ## Verification - `npx vitest run src/__tests__/cases-routes.test.ts src/services/case-handoffs.test.ts src/services/native-runtime/native-case-tools.integration.test.ts src/__tests__/heartbeat-case-handoff.test.ts` (166 passed). - The cases.cases-disabled chat eval is rerun on a temporary branch; results are recorded in the PR conversation. ## Risks - Low risk. The status and message do not change. The error body adds `code` and `remediation`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- server/src/__tests__/cases-routes.test.ts | 10 +++++++ server/src/routes/cases.ts | 17 ++--------- server/src/services/case-handoffs.ts | 3 +- server/src/services/cases-disabled.ts | 29 +++++++++++++++++++ .../native-case-tools.integration.test.ts | 3 ++ .../native-runtime/native-case-tools.ts | 5 ++-- .../native-runtime/native-workflow-tools.ts | 5 ++-- .../paperclip-runner-tool-authority.ts | 3 +- server/src/services/workflows.ts | 5 ++-- 9 files changed, 58 insertions(+), 22 deletions(-) create mode 100644 server/src/services/cases-disabled.ts diff --git a/server/src/__tests__/cases-routes.test.ts b/server/src/__tests__/cases-routes.test.ts index 8d095a25153..d9b5088cc17 100644 --- a/server/src/__tests__/cases-routes.test.ts +++ b/server/src/__tests__/cases-routes.test.ts @@ -229,6 +229,16 @@ describeEmbeddedPostgres("cases routes", () => { await http.get(`/api/cases/${caseRow!.id}/events`).expect(403); }); + it("tells a caller that Cases is off for good and names the setting instead of a substitute", async () => { + const company = await seedCompany("OFFX"); + const response = await request(app(boardActor)).get(`/api/companies/${company.id}/cases`).expect(403); + expect(response.body).toMatchObject({ error: "Cases are disabled", code: "cases_disabled" }); + expect(response.body.remediation).toContain("this denial is final"); + expect(response.body.remediation).toContain("Do not create a task, project or other record in place of the case before the person chooses"); + expect(response.body.remediation).toContain("[Experimental settings](//company/settings/instance/experimental)"); + expect(response.body.remediation).toContain("offer to track the request as an ordinary task instead"); + }); + it("returns not found for unknown case IDs on the former shared routes", async () => { await enableCases(); const http = request(app(boardActor)); diff --git a/server/src/routes/cases.ts b/server/src/routes/cases.ts index db9b0399f0f..ef318a6e835 100644 --- a/server/src/routes/cases.ts +++ b/server/src/routes/cases.ts @@ -48,8 +48,8 @@ import { isUuidLike, } from "@paperclipai/shared"; import { formatAttachmentSize, MAX_ATTACHMENT_BYTES, normalizeContentType } from "../attachment-types.js"; -import { HttpError, badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; -import { getHiddenSettings } from "../services/settings-visibility.js"; +import { HttpError, badRequest, conflict, notFound, unprocessable } from "../errors.js"; +import { casesDisabledError } from "../services/cases-disabled.js"; import { validate } from "../middleware/validate.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { documentAnnotationService, logActivity } from "../services/index.js"; @@ -81,18 +81,7 @@ function eventActorValues(actor: CaseActor) { async function assertCasesEnabled(db: Db) { const experimental = await instanceSettingsService(db).getExperimental(); if (!experimental.enableCases) { - // An operator can hide the Experimental page, so link it only while it is visible. - // Hiding only the Cases key keeps the page but drops its switch, so check both. - const hidden = getHiddenSettings(); - const where = hidden.has("instance.experimental") || hidden.has("instance.experimental.enableCases") - ? "An instance operator controls it." - : "An instance admin turns them on with Cases in [Experimental settings](//company/settings/instance/experimental), " - + "with this conversation's company prefix as ."; - throw forbidden("Cases are disabled", { - code: "cases_disabled", - remediation: "Cases are an experimental feature that is turned off on this instance, so this denial is final and no other route reaches cases. " - + `${where} Agent credentials cannot change that setting. Reply now.`, - }); + throw casesDisabledError(); } } diff --git a/server/src/services/case-handoffs.ts b/server/src/services/case-handoffs.ts index 3ca6f2d1767..1883e8ea2fc 100644 --- a/server/src/services/case-handoffs.ts +++ b/server/src/services/case-handoffs.ts @@ -5,6 +5,7 @@ import type { Db } from "@paperclipai/db"; import { agents, caseAccessSources, caseEvents, caseHandoffs, caseIssueLinks, cases, chatConversations, companies, heartbeatRuns, issues, projects } from "@paperclipai/db"; import { handoffCaseSchema, isUuidLike, type DoneCaseHandoffInput, type HandoffCaseInput, type HandoffCaseResult } from "@paperclipai/shared"; import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; +import { casesDisabledError } from "./cases-disabled.js"; import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js"; import { withExternalAdmissionGuard, type ExternalAdmissionTransaction } from "../modules/external-agents/index.js"; import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js"; @@ -51,7 +52,7 @@ function companyAllowed(actor: AuthorizationActor, companyId: string) { } async function assertEnabled(db: Db) { - if (!(await instanceSettingsService(db).getExperimental()).enableCases) throw forbidden("Cases are disabled"); + if (!(await instanceSettingsService(db).getExperimental()).enableCases) throw casesDisabledError(); } function result(row: CaseHandoffRow, issue: Pick, actor: AuthorizationActor, replayed: boolean): HandoffCaseResult { diff --git a/server/src/services/cases-disabled.ts b/server/src/services/cases-disabled.ts new file mode 100644 index 00000000000..5e27b199f72 --- /dev/null +++ b/server/src/services/cases-disabled.ts @@ -0,0 +1,29 @@ +import { forbidden } from "../errors.js"; +import { getHiddenSettings } from "./settings-visibility.js"; + +export const CASES_DISABLED_CODE = "cases_disabled"; + +function casesDisabledRemediation() { + // An operator can hide the Experimental page, so link it only while it is visible. + // Hiding only the Cases key keeps the page but drops its switch, so check both. + const hidden = getHiddenSettings(); + const where = hidden.has("instance.experimental") || hidden.has("instance.experimental.enableCases") + ? "An instance operator controls it." + : "An instance admin turns them on with Cases in [Experimental settings](//company/settings/instance/experimental), " + + "with this conversation's company prefix as ."; + return "Cases are an experimental feature that is turned off on this instance, so this denial is final and no other route reaches cases or workflows. " + + `${where} Agent credentials cannot change that setting. Do not create a task, project or other record in place of the case before the person chooses. ` + + "Reply now: tell the person Cases is off, say where it is turned on, and offer to track the request as an ordinary task instead."; +} + +/** + * The denial for any case or workflow operation while the instance has Cases off. + * Native tool results carry only the error message, so those callers inline the remediation. + */ +export function casesDisabledError(options: { inlineRemediation?: boolean } = {}) { + const remediation = casesDisabledRemediation(); + return forbidden(options.inlineRemediation ? `Cases are disabled. ${remediation}` : "Cases are disabled", { + code: CASES_DISABLED_CODE, + remediation, + }); +} diff --git a/server/src/services/native-runtime/native-case-tools.integration.test.ts b/server/src/services/native-runtime/native-case-tools.integration.test.ts index b191a4091d0..248d13a858f 100644 --- a/server/src/services/native-runtime/native-case-tools.integration.test.ts +++ b/server/src/services/native-runtime/native-case-tools.integration.test.ts @@ -511,6 +511,9 @@ describe("native case tools through authenticated production routes", () => { const disabled = new PaperclipRunnerToolAuthority(server.db, { ...f, casesEnabled: false }); expect(disabled.definitions().map(tool => tool.name)).not.toContain("read_case"); await expect(disabled.execute({ tool: "read_case", callId: randomUUID(), arguments: {} })).rejects.toThrow("Cases are disabled"); + // Native tool results carry only the message, so it must hold the final, actionable remediation. + await expect(disabled.execute({ tool: "list_workflows", callId: randomUUID(), arguments: {} })).rejects.toThrow( + /^Cases are disabled\. .*this denial is final.*\[Experimental settings\]\(\/\/company\/settings\/instance\/experimental\).*offer to track the request as an ordinary task instead\.$/); const review = new PaperclipRunnerToolAuthority(server.db, { ...f, casesEnabled: true, nativeReview: { nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } }); expect(review.definitions().map(tool => tool.name)).not.toContain("read_case"); diff --git a/server/src/services/native-runtime/native-case-tools.ts b/server/src/services/native-runtime/native-case-tools.ts index 26960c3a6c4..b493d332a06 100644 --- a/server/src/services/native-runtime/native-case-tools.ts +++ b/server/src/services/native-runtime/native-case-tools.ts @@ -4,7 +4,8 @@ import { cases, type Db } from "@paperclipai/db"; import { z } from "zod"; import { caseInitialDocumentsSchema, caseWorkflowDocumentTextSchema, isUuidLike } from "@paperclipai/shared"; import { patchCaseSchema } from "../../routes/cases-schemas.js"; -import { badRequest, forbidden, notFound } from "../../errors.js"; +import { badRequest, notFound } from "../../errors.js"; +import { casesDisabledError } from "../cases-disabled.js"; import { assertActorCanReadCase } from "../case-access.js"; import type { AuthorizationActor } from "../authorization.js"; import { instanceSettingsService } from "../instance-settings.js"; @@ -58,7 +59,7 @@ export async function executeNativeCaseTool(input: { callApi: (receiptKey: string, request: RunnerApiCall) => Promise; }) { if (input.binding.casesEnabled !== true || !(await instanceSettingsService(input.db).getExperimental()).enableCases) { - throw forbidden("Cases are disabled"); + throw casesDisabledError({ inlineRemediation: true }); } const schema = schemas[input.tool as keyof typeof schemas]; if (!schema) throw badRequest("Unknown native case operation"); diff --git a/server/src/services/native-runtime/native-workflow-tools.ts b/server/src/services/native-runtime/native-workflow-tools.ts index d769a50012f..54cc3fbcffa 100644 --- a/server/src/services/native-runtime/native-workflow-tools.ts +++ b/server/src/services/native-runtime/native-workflow-tools.ts @@ -2,7 +2,8 @@ import { createHash } from "node:crypto"; import { z } from "zod"; import { createWorkflowSchema, updateWorkflowSchema, listWorkflowsSchema, readWorkflowQuerySchema } from "@paperclipai/shared"; import type { Db } from "@paperclipai/db"; -import { forbidden, badRequest } from "../../errors.js"; +import { badRequest } from "../../errors.js"; +import { casesDisabledError } from "../cases-disabled.js"; import { requireCasesEnabled } from "../workflows.js"; import { runnerApiCallSchema, type RunnerApiCall } from "./runner-api-client.js"; @@ -20,7 +21,7 @@ export async function executeNativeWorkflowTool(input: { db: Db; binding: { companyId: string; casesEnabled?: boolean }; tool: string; arguments: unknown; callApi: (receiptKey: string, request: RunnerApiCall) => Promise; }) { - if (input.binding.casesEnabled !== true) throw forbidden("Cases are disabled"); + if (input.binding.casesEnabled !== true) throw casesDisabledError({ inlineRemediation: true }); await requireCasesEnabled(input.db); const schema = schemas[input.tool as keyof typeof schemas]; if (!schema) throw badRequest("Unknown workflow operation"); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 11fe6b14140..1afe09e4ea1 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -73,6 +73,7 @@ import { getSecretProvider } from "../../secrets/provider-registry.js"; import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js"; import { workspaceFileResourceService } from "../workspace-file-resources.js"; import { badRequest, forbidden, notFound, HttpError } from "../../errors.js"; +import { casesDisabledError } from "../cases-disabled.js"; import { runnerApiCatalog, searchRunnerApi } from "./runner-api-catalog.js"; import { assertRunnerApiDispatchable, executeRunnerApi, validateRunnerApiCall, RUNNER_API_MAX_BYTES, type RunnerApiCall, type RunnerApiFile, type RunnerApiIo } from "./runner-api-client.js"; import { acquireRunnerApiResponseSlot, runnerApiCompanyCaptureMaxBytes, RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES, RunnerApiResponseLimitError } from "./runner-api-response-limits.js"; @@ -629,7 +630,7 @@ export class PaperclipRunnerToolAuthority { if ((call.tool === "handoff_case" || call.tool === "create_case") && !this.binding.nativeReview) { if (this.binding.workMode && this.binding.workMode !== "standard") throw forbidden("Case tools require standard work mode"); const create = call.tool === "create_case" ? createNativeCaseSchema.parse(call.arguments) : null; - if (this.binding.casesEnabled !== true) throw forbidden("Cases are disabled"); + if (this.binding.casesEnabled !== true) throw casesDisabledError({ inlineRemediation: true }); if (this.binding.museRuntime) await this.binding.assertBridgeAuthority?.(); // Both operations use the principal-scoped handoff ledger. A committed // receipt stays replayable after ownership moved; fresh work reauthorizes. diff --git a/server/src/services/workflows.ts b/server/src/services/workflows.ts index d2a3fb7e875..b73b0ef0493 100644 --- a/server/src/services/workflows.ts +++ b/server/src/services/workflows.ts @@ -3,14 +3,15 @@ import { createHash } from "node:crypto"; import { workflows, type Db } from "@paperclipai/db"; import type { z } from "zod"; import type { createWorkflowSchema, updateWorkflowSchema } from "@paperclipai/shared"; -import { conflict, forbidden, notFound } from "../errors.js"; +import { conflict, notFound } from "../errors.js"; +import { casesDisabledError } from "./cases-disabled.js"; import { instanceSettingsService } from "./instance-settings.js"; import { authorizeCaseCreation } from "./case-mutation-authorization.js"; import type { AuthorizationActor } from "./authorization.js"; import { persistActivity, publishActivity } from "./activity-log.js"; export async function requireCasesEnabled(db: Db) { - if (!(await instanceSettingsService(db).getExperimental()).enableCases) throw forbidden("Cases are disabled"); + if (!(await instanceSettingsService(db).getExperimental()).enableCases) throw casesDisabledError(); } export async function getWorkflow(db: Db, companyId: string, id: string) {