# GET /api/v1/chats/:id Endpoint -- TRD

## Status

Approved

## Overview

New endpoint to fetch a single chat's metadata by ID. The server already exposes `PATCH` and `DELETE` on `/api/v1/chats/:id` but has no `GET` variant. Without it, the client's `MutationQueue.refreshFromServer()` must paginate through every page of `GET /api/v1/chats` after a 409 CAS conflict just to locate one record. This endpoint eliminates that over-fetching with a single cache read.

## Goals

1. Provide a direct `GET /api/v1/chats/:id` endpoint that returns a single chat's metadata from the Redis conversation cache.
2. Expose a corresponding `ApiClient.getConversation(id)` method in the `@coda/core-api` package.
3. Simplify `MutationQueue.refreshFromServer()` from a paginated loop to a single API call.
4. Handle the 404-within-409 race condition (chat deleted between the original 409 and the follow-up GET) consistently with existing deletion flows.

## Architecture

The endpoint follows the same pattern as every other chat route: an Express handler reads from the Redis conversation cache via `conversationCache.getConversationMeta()`. There is no database fallback -- chat metadata is Redis-authoritative because all mutations flow through the cache layer.

```
Client (MutationQueue)
  |
  |  GET /api/v1/chats/:id
  v
ows-grass (JWT verification)
  |
  v
ows-coda Express router
  |
  v
handleGetConversation handler
  |
  v
Redis HGETALL (conversationCache.getConversationMeta)
  |
  +---> 200 + Conversation JSON  (found)
  +---> 404                      (null / not found)
```

Three packages are touched:

| Package          | Change                                         |
| ---------------- | ---------------------------------------------- |
| `server`         | New handler + route registration               |
| `@coda/core-api` | New `ApiClient.getConversation()` method       |
| `client`         | Simplified `MutationQueue.refreshFromServer()` |

## Detailed Design

### Server handler

A new `handleGetConversation` export is added to `server/src/routes/conversation-handlers.ts`.

- Extracts `identityId` from `res.locals` and `conversationId` from the route param.
- Calls `conversationCache.getConversationMeta(identityId, conversationId)`.
- Returns `200` with the `Conversation` JSON on hit, `404` with `{ error: "Conversation not found" }` on miss.
- Catches unexpected errors and returns `500`.

Response codes:

| Status | Condition                                          |
| ------ | -------------------------------------------------- |
| 200    | Chat found -- returns `Conversation` JSON          |
| 400    | Invalid UUID (`validateUuidParam` middleware)      |
| 401    | Missing or invalid auth (`requireAuth` middleware) |
| 404    | Cache returns null                                 |
| 500    | Unexpected error                                   |

Response shape (200) -- the existing `Conversation` type, no new types introduced:

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "My Chat",
  "starred": false,
  "createdAt": "2026-01-01T00:00:00.000Z",
  "updatedAt": "2026-01-02T12:30:00.000Z"
}
```

### Route registration

The route is registered in `server/src/routes/chat-routes.ts` in the "Detail routes" section. Route ordering is critical:

```
GET /           -> handleListConversations        (collection -- before .param)
GET /deleted    -> handleListDeletedChats          (literal match -- before .param)
chatsRouter.param("id", validateUuidParam)         <- UUID guard
GET /:id        -> handleGetConversation   <- NEW  (detail -- after .param)
GET /:id/messages -> handleGetMessages             (detail -- after .param)
```

`GET /deleted` must remain registered before any `/:id` route so the literal string `"deleted"` wins over the param. The new `GET /:id` must appear after the `.param("id", validateUuidParam)` call so the UUID validation middleware runs and produces 400 for malformed IDs.

### API client method

A new `getConversation(id: string): Promise<Conversation>` method is added to `ApiClient` in `api/src/client.ts`.

- Sends `GET` to `ROUTES.CHAT(id)` with auth headers.
- Uses `fetchWithRetry`. 404 is a permanent error (not in `TRANSIENT_STATUS_CODES`) and will not be retried -- this property is relied upon by the client's 404 handler.
- Returns parsed `Conversation` JSON on success.
- Throws `NetworkError` on non-2xx, with `statusCode` matching the HTTP status.

The JSDoc on `ROUTES.CHAT` in `api/src/constants.ts` is updated from `PATCH / DELETE` to `GET / PATCH / DELETE`.

### Client: MutationQueue.refreshFromServer

The existing paginated `listChats` loop in `client/src/offline/mutation-queue.ts` is replaced with:

```ts
const serverChat = await this.api.getConversation(chatId);
const existing = await this.store.getChat(chatId);
await this.store.putChat({
  ...serverChat,
  cachedAt: existing?.cachedAt ?? new Date().toISOString(),
});
```

**404 handling:** If `getConversation` throws a `NetworkError` with `statusCode === 404`, the chat was deleted on the server in the narrow window between the original 409 and the follow-up GET. The handler deletes the local chat record and broadcasts `chats:deleted`, mirroring the existing 404 branch in `flush()`. Note that `removeMutation` is already called in `flush()` before `refreshFromServer` is entered, so the catch block does not call it again.

## Alternatives Explored

### Filter parameter on the list endpoint

Adding a `?id=<uuid>` filter to `GET /api/v1/chats` was considered. This would avoid a new route but introduces ambiguity (is the response a list with one item or a single object?), complicates the list handler, and still returns a paginated envelope for a single-record lookup. A dedicated `GET /:id` is the standard REST pattern and is simpler for both server and client.

### Database fallback on cache miss

Adding a MySQL fallback when Redis returns null was considered. This was rejected because chat metadata is Redis-authoritative -- all create/update/delete operations go through the cache, and the cache is the source of truth with a 7-day TTL. A cache miss genuinely means the chat does not exist (or has expired). A DB fallback would add latency, complexity, and a risk of returning stale data that the cache has already evicted.

## Cost Analysis

Minimal. The change adds:

- ~15 lines of handler code (follows existing patterns exactly).
- ~15 lines of API client method (follows existing `fetchWithRetry` pattern).
- ~10 lines net reduction in the client's `refreshFromServer` (replaces a paginated loop with a single call).
- No new dependencies, types, schemas, or infrastructure.

The primary cost saving is on the client side: eliminating multi-page fetches of the entire chat list after every 409 conflict.

## Performance Analysis

- **Server:** Single `HGETALL` against Redis -- sub-millisecond. No database query, no pagination, no aggregation.
- **Client:** Replaces N paginated `GET /api/v1/chats?cursor=...` requests (where N depends on the user's chat count) with exactly one `GET /api/v1/chats/:id` request.
- **Network:** One round-trip instead of potentially many. Payload is a single `Conversation` object (~150 bytes) instead of pages of conversations.

## Scaling Characteristics

Trivial. Each request performs a single Redis `HGETALL` on a key scoped to the user's identity. This operation is O(n) where n is the number of fields in the hash (fixed at 5 for a `Conversation`), so effectively O(1). No fan-out, no aggregation, no cross-user data access.

## Breakdown Points & Mitigations

| Breakdown Point                                                          | Likelihood | Impact                                                                   | Mitigation                                                                                                                                                     |
| ------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **404 when chat deleted between 409 and GET**                            | Low        | Low -- user loses a chat that was already server-deleted                 | Handled explicitly: delete local record, broadcast `chats:deleted`, consistent with existing 404 flows in `flush()`                                            |
| **Route ordering bug: `GET /deleted` matched as `GET /:id`**             | Low        | High -- `listDeletedChats` would break                                   | `GET /deleted` is already registered before `.param("id")` and will remain so. The implementation plan specifies exact placement. Functional tests cover this. |
| **Route ordering bug: `GET /:id` registered before `.param` validation** | Low        | Medium -- malformed UUIDs would reach the handler instead of getting 400 | Placement is specified after the `.param` call. Unit tests for 400 on invalid UUIDs exist at the middleware level.                                             |
| **Redis unavailability**                                                 | Low        | Medium -- endpoint returns 500                                           | Same exposure as all other chat endpoints. Existing Redis health checks and reconnection logic apply.                                                          |

## Decision Log

| Decision                                                           | Rationale                                                                                                                  |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| No DB fallback                                                     | Chat metadata is Redis-authoritative; a cache miss means the chat does not exist                                           |
| 404 triggers local deletion + broadcast                            | Consistency with the existing 404 branch in `flush()` -- every "chat is gone from server" path should clean up immediately |
| `fetchWithRetry` for the API client method                         | Consistency with all other `ApiClient` methods; 404 is not in `TRANSIENT_STATUS_CODES` so it will not be retried           |
| Single `Conversation` object response (not wrapped in an envelope) | Matches REST convention for detail endpoints and avoids the pagination envelope used by the list endpoint                  |

## Dependencies

| Dependency                                 | Status | Notes                                |
| ------------------------------------------ | ------ | ------------------------------------ |
| `conversationCache.getConversationMeta()`  | Exists | Already used by other handlers       |
| `validateUuidParam` middleware             | Exists | Already applied to all `/:id` routes |
| `requireAuth` middleware                   | Exists | Already applied to the router        |
| `Conversation` type from `@coda/core-api`  | Exists | No new types needed                  |
| `NetworkError` class from `@coda/core-api` | Exists | Used for 404 detection in the client |

No new external dependencies are introduced.

## Testing Strategy

### Unit tests (`server/src/routes/__tests__/conversation-handlers.test.ts`)

- `handleGetConversation` returns 200 with the conversation when cache finds it.
- `handleGetConversation` returns 404 when `getConversationMeta` returns null.

### Functional tests (`server/src/__tests__/functional/conversation-lifecycle.test.ts`)

- Create a conversation, then `GET /api/v1/chats/:id` -- assert full response shape matches `Conversation`.
- `GET /api/v1/chats/<unknown-uuid>` -- assert 404.

### API client tests (`api/src/__tests__/client.test.ts`)

- `getConversation` sends GET to the correct URL and returns parsed JSON.
- `getConversation` throws `NetworkError` with `statusCode: 404` when the server returns 404.

### Client tests (`client/src/offline/__tests__/mutation-queue.test.ts`)

- 409 conflict triggers `getConversation` with the conflicted chat ID, stores the server-returned data.
- `getConversation` is called exactly once (no pagination).
- 404-within-409 race: `getConversation` throws 404, local chat is deleted, `chats:deleted` is broadcast.

## Rollout Plan

1. **Merge:** Single PR covering server, API, and client changes. All three packages are versioned together in the monorepo.
2. **Deploy:** Standard deployment pipeline. The new endpoint is additive -- it does not modify or remove any existing routes, so backward compatibility is maintained.
3. **Feature flag:** None required. The endpoint is unconditionally available. The client change (using `getConversation` instead of `listChats` pagination) is purely an optimization with identical user-visible behavior.
4. **Monitoring:** No new metrics or alerts needed. The endpoint uses existing request logging and error tracking. A spike in 404s on this route would indicate a cache eviction or data integrity issue, but that would also manifest on other cache-dependent endpoints.

## Open Questions

None. This is a straightforward, narrowly-scoped endpoint that follows established patterns across all three packages.
