Files
PaperClipAI/server/src/routes/experimental-api-paths.ts
T
DottaandClaude Opus 5.5 a8f21a171c fix(cases): hold direct case status writes to terminal handoff authority (#20686)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The experimental Cases feature closes a case through a terminal
handoff to `done`
> - The terminal handoff lets an agent close a case only when the agent
owns the case's primary task
> - The direct case write routes (`PATCH /api/cases/:id` and the keyed
upsert on `POST /api/companies/:companyId/cases`) also write `status`
and `completedAt`
> - These routes did not apply the primary-task ownership rule to agent
status writes
> - This pull request applies the same ownership rule to those routes,
and sends `done` through the terminal handoff
> - The benefit is that a case closes only through the authority and
gates that the product describes

## Linked Issues or Issue Description

No public issue exists. This is the bug description.

**What happened**

An agent that did not own a case's primary task tried `handoff_case` to
`done` and got "Agents may complete only their own case work". The same
agent then sent `PATCH /api/cases/:id` with `status: "done"`. The route
returned 200 and closed the case. A chat eval (`cases.close-case`)
recorded this sequence.

**Expected behavior**

An agent changes a case with a primary task to or from
`done`/`cancelled` only with the authority that the terminal handoff
requires. `done` goes through the terminal handoff, so primary-task
completion keeps its normal gates (`doc/PRODUCT.md`, Cases section). The
dedicated `update_case` tool already excludes `status`.

**Steps to reproduce**

1. Create a case with a primary task that is assigned to agent A.
2. As agent B in the same company, send `PATCH /api/cases/:id` with
`{"status":"done"}`.
3. Before this change, the response is 200 and the case status is
`done`.

## What Changed

- `server/src/services/case-handoffs.ts`: add
`assertCaseStatusWriteAuthority`. For an agent actor, on a case that has
a primary task, a status change into or out of `done`/`cancelled` must
pass the same check as the terminal handoff (`authorizeTerminalTask`:
the agent is the primary task's assignee). A change to `done` then
returns 422 and names the terminal handoff. Board actors, non-terminal
status changes and fact edits do not change.
- `server/src/routes/cases.ts`: call the check inside the case write
transaction in `PATCH /api/cases/:id` and in the keyed upsert branch of
`POST /api/companies/:companyId/cases`.
- The terminal ownership denial now names the owning agent and the
primary task, and asks the caller to contact that agent on the task. The
message keeps its old first sentence.
- The keyed upsert falls back to the locked case row (not the pre-lock
read), so an upsert without `status` cannot undo a concurrent close.
- `server/src/routes/experimental-api-paths.ts`: add a catalog summary
and description for `PATCH /api/cases/{id}` that states the closure
rule.
- `server/src/__tests__/cases-routes.test.ts`: regression test for
non-owner `done`/`cancelled` through PATCH and upsert, the owner's
`done` through PATCH (422), owner cancel, non-owner reopen, and board
close.

## Verification

- `cd server && npx vitest run src/__tests__/cases-routes.test.ts -t
"terminal handoff's primary-task"`: fails before the route change
(`expected 403, got 200`) and passes after it.
- `cd server && npx vitest run src/__tests__/cases-routes.test.ts
src/__tests__/case-mutation-fence.integration.test.ts
src/__tests__/case-handoff-native.integration.test.ts
src/__tests__/case-handoff-legacy.integration.test.ts
src/services/case-handoffs.test.ts
src/services/native-runtime/native-case-tools.integration.test.ts
src/__tests__/heartbeat-case-terminal-handoff.test.ts
src/__tests__/openapi-routes.test.ts
src/__tests__/case-history-api-discovery.test.ts
src/services/native-runtime/runner-api.test.ts`: all pass. (One local
run of the legacy integration suite failed in fixture scratch
preparation under load; two reruns with the change pass.)
- `server` `tsc --noEmit`: no errors in the changed files. The fresh
worktree had unrelated errors from unbuilt workspace packages; CI covers
the full typecheck.
- Live chat eval
(`chat-features.runner-opencode.local.cases.close-case`, run
42936492869, at the first commit): the non-owner agent's terminal
handoff was denied with the owner named, and the agent did not change
case status through another route. The eval did not pass: the first chat
turn did not finish inside the scenario's 180-second observation window.
That is a separate latency issue in the eval.

## Risks

- Behavior change for agents: an agent can no longer set `done` through
PATCH on a case with a primary task, and a non-owner agent can no longer
cancel or reopen it. The agent skill reference already says "Do not
change case status separately".
- Cases without a primary task keep the old behavior, because they have
no owner to check and the terminal handoff does not apply to them.
- The check reads the primary task without a row lock, to keep the task
-> run -> case lock order. A concurrent reassignment can race one
request. The terminal handoff itself still locks and rechecks.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use and code execution.

## 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
- [x] All Paperclip CI gates are green
- [x] 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>
2026-11-10 20:41:27 -06:00

69 lines
5.5 KiB
TypeScript

// Experimental routes share their runtime validators with OpenAPI discovery.
import { z } from "zod";
import * as caseSchemas from "./cases-schemas.js";
import * as sharedSchemas from "@paperclipai/shared";
export const experimentalApiPaths: readonly [string, string, z.ZodTypeAny | undefined][] = [
["post", "/api/companies/{companyId}/cases", caseSchemas.createCaseSchema],
["get", "/api/companies/{companyId}/cases", undefined],
["get", "/api/companies/{companyId}/cases/types", undefined],
["get", "/api/cases/{id}/documents/{key}", undefined],
["get", "/api/cases/{id}/documents/{key}/annotations", undefined],
["get", "/api/cases/{id}/documents/{key}/annotations/{threadId}", undefined],
["post", "/api/cases/{id}/documents/{key}/annotations", sharedSchemas.createDocumentAnnotationThreadSchema],
["post", "/api/cases/{id}/documents/{key}/annotations/{threadId}/comments", sharedSchemas.createDocumentAnnotationCommentSchema],
["patch", "/api/cases/{id}/documents/{key}/annotations/{threadId}", sharedSchemas.updateDocumentAnnotationThreadSchema],
["put", "/api/cases/{id}/documents/{key}", caseSchemas.upsertCaseDocumentSchema],
["post", "/api/cases/{id}/documents/{key}/lock", undefined],
["post", "/api/cases/{id}/documents/{key}/unlock", undefined],
["post", "/api/cases/{id}/documents/{key}/revisions/{revisionId}/restore", undefined],
["delete", "/api/cases/{id}/documents/{key}", undefined],
["post", "/api/cases/{id}/links", caseSchemas.createIssueLinkSchema],
["post", "/api/cases/{id}/attachments", undefined],
["get", "/api/cases/{id}/events", undefined],
["get", "/api/cases/{id}/documents/{key}/revisions", undefined],
["get", "/api/issues/{issueId}/cases", undefined],
["get", "/api/cases/{id}", undefined],
["patch", "/api/cases/{id}", caseSchemas.patchCaseSchema],
["get", "/api/companies/{companyId}/smoke-lab/oauth/authorize", undefined],
["post", "/api/companies/{companyId}/smoke-lab/oauth/authorize", undefined],
["post", "/api/companies/{companyId}/smoke-lab/oauth/token", undefined],
["get", "/api/companies/{companyId}/smoke-lab/oauth/userinfo", undefined],
["post", "/api/companies/{companyId}/smoke-lab/oauth/revoke", undefined],
["get", "/api/companies/{companyId}/smoke-lab/services", undefined],
["post", "/api/companies/{companyId}/smoke-lab/services/start", undefined],
["post", "/api/companies/{companyId}/smoke-lab/services/stop", undefined],
["post", "/api/companies/{companyId}/smoke-lab/install-fixtures", undefined],
["get", "/api/companies/{companyId}/smoke-lab/runs", undefined],
["post", "/api/companies/{companyId}/smoke-lab/runs", sharedSchemas.createSmokeRunSchema],
["get", "/api/companies/{companyId}/smoke-lab/runs/{runId}", undefined],
["patch", "/api/companies/{companyId}/smoke-lab/runs/{runId}", sharedSchemas.updateSmokeRunSchema],
["post", "/api/companies/{companyId}/smoke-lab/runs/{runId}/steps", sharedSchemas.recordSmokeRunStepSchema],
["post", "/api/companies/{companyId}/smoke-lab/reset", undefined],
];
// Searchable summaries for experimental reads agents need to find. Routes not
// listed here keep the generated method-and-path summary.
export const experimentalApiDocs: Record<string, { summary: string; description: string }> = {
"GET /api/companies/{companyId}/cases": { summary: "List company cases",
description: "Lists the cases the caller can read, with each case's id, identifier, title, status and primary task (workIssueId)." },
"GET /api/issues/{issueId}/cases": { summary: "Find the cases for a task",
description: "Accepts a task UUID or identifier and returns each readable case linked to that task, as { id, role, case: { id, identifier, ... } }. The top-level id is the link; pass case.id to read_case or the case audit history." },
"GET /api/cases/{id}": { summary: "Read a case and its current facts",
description: "Returns the case identifier, title, status, current facts (fields) and primary task." },
"PATCH /api/cases/{id}": { summary: "Update a case's facts, labels or status",
description: "Updates title, summary, fields, labels, project, parent case or status. For a case with a primary task, agents do not close it here: the agent assigned to that task records a terminal handoff to done, and only that agent may cancel or reopen the case." },
"GET /api/cases/{id}/events": { summary: "Read a case's audit history of fact changes, handoffs and links",
description: "Newest first. Each event has its kind (such as fields_changed, status_changed, issue_linked), the actor and time. An agent actor has actorAgentId and actorAgentName; a person has actorType user and actorUserId, without a display name. A fields_changed event records who changed the facts and when; it does not retain the previous fact values. Document edits are in document revisions and handoffs in the case handoff history." },
"GET /api/cases/{id}/documents/{key}/revisions": { summary: "Read a case document's revision history",
description: "Returns every revision of a case-owned document, newest first, with body, author and time. An agent author has actorAgentName; a person has createdByUserId, without a display name." },
};
// Query contracts shared with the runtime parser where one exists.
export const experimentalApiQueries: Record<string, z.ZodObject<any>> = {
"GET /api/companies/{companyId}/cases": caseSchemas.listCasesQuerySchema,
"GET /api/cases/{id}/events": caseSchemas.listEventsQuerySchema,
"GET /api/cases/{id}": caseSchemas.caseDetailQuerySchema,
"PATCH /api/cases/{id}": caseSchemas.caseDetailQuerySchema,
};