--- name: api-conventions description: Response-body conventions for the Platypus backend API — the `error` key for failures, the `message` key for 2xx status messages, and when to throw a typed error instead of returning a response. Use when adding or changing a backend route or its tests. --- # API Response Conventions ## The two keys `error` carries a failure. `message` carries a 2xx status line for an operation that has no resource to return. ```typescript return c.json({ error: "Workspace not found" }, 404); return c.json({ message: "Board deleted" }); ``` Never `message` on a 4xx/5xx — that is the one crossing the codebase does not make. A delete or action with nothing to return answers `200 { message }` — never a bodiless 204, and never `{ success: true }`. ## The error seam Five cross-cutting failures are **thrown**, not returned. A single `onError` seam maps each to its status and emits the same `{ error }` body, so a domain rule states its message once instead of once per caller: | Throw | Becomes | | ------------------ | ------- | | `NotFoundError` | 404 | | `ValidationError` | 400 | | `LockedError` | 403 | | `ConflictError` | 409 | | `UnsupportedError` | 501 | A Postgres unique violation also maps to 409 — detect it through the shared helper rather than re-reading the driver's error shape. So does a foreign-key violation, as a backstop only: a delete guarded by a `restrict` reference should throw a `ConflictError` naming what still uses the row first. ```typescript if (!row) throw new NotFoundError("Card not found"); throw new ValidationError("Invalid user assignee"); ``` A resource referenced in the request body that isn't in this Organization, or isn't Shared, is a `NotFoundError` (404) naming what couldn't be used — not a 422, and no extra detail fields beside `error`. Throwing is what lets one rule serve more than one surface: the Kanban rules answer both the HTTP routes and the Agent tool set from a single place. Reach for the seam whenever the failure is one of those five; a 4xx that only one route can produce still answers inline with `c.json({ error }, status)`. Both are current and roughly equally common — the seam is not a migration you should finish. ## Test assertions Assert against the key the response actually uses: ```typescript expect(body.error).toBe("Invalid user assignee"); expect(await res.json()).toEqual({ message: "Board deleted" }); ``` A service test covering a thrown failure asserts the error, not a body: ```typescript await expect(fn()).rejects.toThrow(NotFoundError); ```