Operant Studio
OPER-72

Ledger: /tasks/[id] and /tasks/[id]/comments should resolve human identifier OR UUID

Suggested
Justin Cooke

Body

---
feature_id: FEAT-studio-platform
size: S
phi_in_scope: false
has_migration: false
target_branch: main
module: studio
verification_cmd: "pnpm --filter @operant-studio/web test -- api/studio/tasks/id-or-identifier.test.ts && curl -sfo /dev/null https://operant-studio-web.vercel.app/api/studio/portico/tasks/POR-SMOKE-CANARY-V2 -H 'x-api-key: $CUSTOM_CRED'"
---

## Context

`GET /api/studio/{team}/tasks/<identifier>` and `POST /api/studio/{team}/tasks/<identifier>/comments` return `404 no_membership` for well-formed identifiers (e.g. `POR-SMOKE-CANARY-V2`) that resolve fine via the LIST endpoint. Consumers must resolve identifier -> UUID via LIST first, doubling API calls.

**Filed from POR-505 (Portico team) [REDACTED-DOB]. Moved to operant team [REDACTED-DOB]** because the fix lives in this repo, not Portico:

- Route: `apps/web/src/app/api/studio/[team]/tasks/[id]/route.ts` (GET + PATCH)
- Sibling: `apps/web/src/app/api/studio/[team]/tasks/[id]/comments/route.ts` (+ `[commentId]/route.ts`)
- Sibling: `apps/web/src/app/api/studio/[team]/tasks/[id]/attachments/route.ts` (+ `[attachmentId]/route.ts`)
- Sibling: `apps/web/src/app/api/studio/[team]/tasks/[id]/review/**/route.ts`

Current implementation (GET):

```ts
const task = await db.task.findFirst({ where: { id }, ... });
if (!task) throw new AuthError("no_membership", 404);
```

The `where: { id }` clause only matches on UUID (cuid2). Should try `identifier` when the segment doesn't look like a cuid2.

**Reproduction (verified [REDACTED-DOB]):**

```
GET /api/studio/portico/tasks/POR-SMOKE-CANARY-V2 -> 404
GET /api/studio/portico/tasks/cms6np6kz0043kz04jlal3st9 -> 200
GET /api/studio/portico/tasks?identifier=POR-SMOKE-CANARY-V2 -> 200 (single match)
```

**Impact:** every consumer (portico's `scripts/smoke-canary.sh`, `scripts/parse-bounce-signal.sh`, `agent-dispatch-smoke.yml`; Sweetness's session-long POR-* + OPER-* PATCH loop) must LIST-then-UUID for every write. Doubles API calls. Bit portico for 17 days (POR-455). Bit this Sweetness session on every task walk.

**Related prior art:**
- `portico#1203` — dep-map switched from GET-by-identifier to LIST-then-UUID as a *client-side* workaround. This ticket removes the need for that workaround.
- POR-505 — original filing from portico consumer side; canceled and moved here.

## Acceptance Criteria

- [ ] `GET /api/studio/portico/tasks/POR-SMOKE-CANARY-V2` returns 200 (same body as UUID-path variant).
- [ ] `PATCH /api/studio/portico/tasks/POR-SMOKE-CANARY-V2` accepts human identifier and updates the correct row.
- [ ] `POST /api/studio/portico/tasks/POR-SMOKE-CANARY-V2/comments` with team bearer returns 200/201.
- [ ] Same fix applied uniformly to every sibling route with `[id]` param (comments, attachments, review, ship, etc.) — grep for `where: { id }` in `apps/web/src/app/api/studio/[team]/tasks/[id]/**/route.ts`.
- [ ] Unit test: given a cuid2-shaped segment, look up by `id`; given anything else, look up by `identifier` scoped to `teamId`. A team can't reach another team's identifier.
- [ ] After deploy: Portico's `CANARY_LEDGER_TASK_ID` repo secret can be removed; `agent-dispatch-smoke.yml`'s identifier fallback (`POR-SMOKE-CANARY-V2`) starts resolving; smoke stays green with no portico-side code change.

## Implementation Notes

- CUID2 shape check: `/^c[a-z0-9]{24}$/` is a safe heuristic (all existing Ledger IDs match). If ambiguous, prefer `id` first, fall back to `identifier`.
- Both branches must remain **team-scoped** via `getScopedDb(team.id)` so cross-team identifier leakage stays impossible.
- Emit an AuditLog note only on PATCH resolution-by-identifier (not GET) to keep audit surface small.

Attachments

Loading attachments…

Comments

Loading comments…