fix(cases): say a Cases-disabled denial is final and name the Experimental setting (#20892)

## 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](/<prefix>/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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 13:36:08 -06:00
1 parent 6dc36700b1
commit eb5ad4c920
9 files changed
+58 -22

No files matched your search

+10
View File
@@ -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](/<prefix>/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));
+3 -14
View File
@@ -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](/<prefix>/company/settings/instance/experimental), "
+ "with this conversation's company prefix as <prefix>.";
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();
}
}
+2 -1
View File
@@ -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<IssueRow, "id" | "identifier">, actor: AuthorizationActor, replayed: boolean): HandoffCaseResult {
+29
View File
@@ -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](/<prefix>/company/settings/instance/experimental), "
+ "with this conversation's company prefix as <prefix>.";
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,
});
}
@@ -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\]\(\/<prefix>\/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");
@@ -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<unknown>;
}) {
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");
@@ -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<unknown>;
}) {
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");
@@ -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.
+3 -2
View File
@@ -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) {