# Message Status & Stream Persister -- TRD

## Status

Approved -- implemented on `NOTICKET_tests` branch (2026-03-18).

## Overview

This change adds a `status` lifecycle column to the `messages` table and replaces the batch-at-end persistence pattern with incremental writes during streaming via a new `StreamPersister` class.

**Before:** Messages were persisted in two disconnected phases. The user message was fire-and-forget at stream start (`persistUserMessageAsync`). The assistant message, tool calls, thoughts, and sources were assembled in memory and written in a single transaction at stream end (`persistTurnAsync` via `turn-assembler.ts` and `persister.ts`). If the stream was interrupted -- page refresh, server crash, OOM kill -- the entire assistant turn was lost. There was no way for the client or backend to distinguish a completed message from one that was mid-flight or failed.

**After:** Both messages are created in DB before streaming begins. Satellite records (tool calls, thoughts, sources) are persisted individually as they arrive during the stream. A `status` enum (`pending -> streaming -> complete | error`) tracks where each assistant message is in its lifecycle. If the server crashes mid-stream, the DB contains all data received up to that point, and the message is visibly `pending` or `streaming` rather than silently missing.

## Goals

### Goals

- **Crash resilience:** No assistant data loss on mid-stream interruption. Partial responses are recoverable.
- **Observable lifecycle:** Every assistant message carries a `status` that the client can use to show appropriate UI (loading indicator, error state, regenerate option).
- **Simplified stream handler:** The handler delegates all DB writes to `StreamPersister` and keeps only Redis cache + auto-naming as its responsibilities.
- **Clean separation of concerns:** `StreamPersister` owns the DB lifecycle for one streaming request. The handler owns SSE dispatch. The repository owns read queries.

### Non-goals

- **Explicit cancel button:** The `cancelled` status exists in the enum but the cancel endpoint (`POST /chats/:id/cancel`) is deferred. The enum is forward-compatible.
- **Stream reconnection / resumption:** If the client disconnects, the server continues generating and marks the message `complete`. Reconnecting to a partial stream is a future enhancement.
- **Stale message cleanup:** Messages stuck in `pending`/`streaming` after a crash are not automatically cleaned up. A future startup sweep (`UPDATE ... WHERE status IN ('pending','streaming') AND updated_at < NOW() - INTERVAL 5 MINUTE`) is planned but not included here.
- **Optimistic client rendering:** The client treats `pending` and `streaming` the same as `error` -- show the message with a regenerate option, not a spinner.

## Architecture

### Status Enum Lifecycle

```mermaid
stateDiagram-v2
    direction LR

    state "User Message" as user {
        [*] --> complete_u: begin()
        complete_u: complete
    }

    state "Assistant Message" as assistant {
        [*] --> pending: begin()
        pending --> streaming: startStreaming()
        streaming --> complete: complete()
        pending --> error: fail()
        streaming --> error: fail()
        pending --> cancelled: [future] cancel endpoint
        streaming --> cancelled: [future] cancel endpoint
    }
```

**User messages** are created as `complete` immediately -- their content (the query) is fully known at creation time. They have no lifecycle transitions.

**Assistant messages** go through `pending -> streaming -> complete` on the happy path. The `pending` state means the message row exists in the database but no LLM output has arrived yet. The transition to `streaming` happens when the first text chunk arrives from the LLM. `complete` is set when `converseWithTools` finishes and the final content, usage stats, and timing are written. If an error occurs at any point, the message transitions to `error`.

The `cancelled` status is reserved for a future explicit cancel endpoint and is never set by socket disconnection. When a client disconnects (page refresh), the server continues generating and ultimately marks the message `complete`.

### StreamPersister Class

`StreamPersister` is a per-stream object instantiated by `ConversationRepository.createStreamPersister()`. Each SSE streaming request gets its own instance. It owns:

- The generated `userMsgId` and `assistantMsgId` (UUIDv4, created internally)
- The `finalized` boolean that prevents double-transition
- The `pendingWrites` array that tracks fire-and-forget promises
- The `thoughtCounter` for ordering reasoning blocks
- The timing baseline (`streamStartedAt`) for computing `totalResponseTimeMs`

### Data Flow

```
Client POST /chats/:id/stream
  |
  v
stream-handler.ts
  |
  +-- repo.createStreamPersister(identityId, chatId)
  |     returns StreamPersister | null (null if DB not configured)
  |
  +-- persister.begin({ query, modelId, provider })          [AWAITED]
  |     |-- $transaction:
  |     |     user.findOrCreate()
  |     |     messageTree.findLastAssistantMessage()
  |     |     chat.create() (upsert)
  |     |     appendMessage(user, status: complete)
  |     |     appendMessage(assistant, status: pending, content: "")
  |     +-- returns { userMsgId, assistantMsgId }
  |
  +-- SSE: message_start { userMessageId, assistantMessageId }
  |
  +-- converseWithTools({ ... callbacks ... })
  |     |
  |     +-- onChunk(text):
  |     |     persister.startStreaming()  [fire-and-forget, once]
  |     |     SSE: chunk
  |     |
  |     +-- onToolExecution(data):
  |     |     persister.addToolCall(...)  [fire-and-forget, per tool use]
  |     |     SSE: tool events
  |     |
  |     +-- onReasoning(text | null):
  |     |     text != null: accumulate in reasoningChunks[]
  |     |     text == null: persister.addThought(joined)  [fire-and-forget]
  |     |     SSE: reasoning
  |     |
  |     +-- onSources(sources):
  |           persister.addSources(sources)  [fire-and-forget]
  |           SSE: sources
  |
  +-- persister.complete({ content, usage })                 [AWAITED]
  |     |-- awaitPending() (Promise.allSettled barrier)
  |     +-- prisma.message.update(content, status: complete, usage, timing)
  |
  +-- Redis cache write + auto-naming (unchanged)
  |
  +-- SSE: { done: true, messageId, userMessageId }
  +-- res.end()
```

On error at any point after `begin()`:

```
catch (error):
  persister.fail()                                           [AWAITED]
    |-- awaitPending() (Promise.allSettled barrier)
    +-- prisma.message.update(status: error)
  SSE: error frame
  res.end()
```

## Detailed Design

### Status Transitions

| Method             | From Status              | To Status   | Blocking?       | Notes                                                                                                                          |
| ------------------ | ------------------------ | ----------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `begin()`          | (none)                   | `pending`   | Awaited         | Creates both messages in a transaction. User message set to `complete`. Assistant message set to `pending` with `content: ""`. |
| `startStreaming()` | `pending`                | `streaming` | Fire-and-forget | Called once on first `onChunk`. Updates via `updateStatus()` which uses `void promise.catch()`.                                |
| `addToolCall()`    | (any)                    | (no change) | Fire-and-forget | Inserts into `message_tool_calls`. Tracked in `pendingWrites[]`.                                                               |
| `addThought()`     | (any)                    | (no change) | Fire-and-forget | Inserts into `message_thoughts` with auto-incrementing `stepOrder`. Tracked in `pendingWrites[]`. Empty strings are skipped.   |
| `addSources()`     | (any)                    | (no change) | Fire-and-forget | Inserts into `message_sources`. Tracked in `pendingWrites[]`. Empty arrays are skipped.                                        |
| `complete()`       | `pending` or `streaming` | `complete`  | Awaited         | Calls `awaitPending()` first (Promise.allSettled), then writes final content + usage + timing.                                 |
| `fail()`           | `pending` or `streaming` | `error`     | Awaited         | Calls `awaitPending()` first, then sets `status: 'error'`. Safe to call before `begin()` (returns silently).                   |

### Prisma Migration

```sql
ALTER TABLE messages
  ADD COLUMN status ENUM('pending', 'streaming', 'complete', 'error', 'cancelled')
  NOT NULL DEFAULT 'pending'
  AFTER content;

-- Backfill: all pre-existing messages are already complete
UPDATE messages SET status = 'complete' WHERE status = 'pending';
```

No index on `status` alone. The existing composite indexes on `(chat_id, ...)` cover all query patterns. Adding a single-column index on a low-cardinality enum would waste space with minimal benefit -- queries always filter by `chat_id` first.

**Deployment safety:** The `DEFAULT 'pending'` is safe for old code because old code never reads the column. The immediate backfill to `'complete'` means no existing message appears broken to new code. The migration runs via `prisma migrate deploy` in the Docker `migrate` service, which executes before server startup.

### StreamPersister API

```ts
class StreamPersister {
  // Construction -- via ConversationRepository.createStreamPersister()
  constructor(codaServices, logger, identityId, chatId, hmacSecret, aesKey);

  // Lifecycle
  async begin({
    query,
    modelId,
    provider,
  }): Promise<{ userMsgId; assistantMsgId }>;
  startStreaming(): void; // fire-and-forget
  addToolCall(input: ToolCallInput): void; // fire-and-forget
  addThought(reasonText: string): void; // fire-and-forget
  addSources(sources: SourceLink[]): void; // fire-and-forget
  async complete({ content, usage }): Promise<void>; // awaited
  async fail(): Promise<void>; // awaited
}
```

**`begin()` transaction contents (5 operations):**

1. Hash + encrypt identity, upsert user
2. `findLastAssistantMessage(chatId)` to resolve `parentMessageId`
3. Chat upsert (sets `rootMessageId` on first message)
4. `appendMessage()` for user message (status: `complete`)
5. `appendMessage()` for assistant message (status: `pending`, content: `""`)

Model resolution (`findByExternalId`) happens outside the transaction to avoid holding it open for the network call.

**Fire-and-forget pattern:** Satellite methods (`addToolCall`, `addThought`, `addSources`) call their respective `satellite.insert*()` method and push the resulting promise into `pendingWrites[]`. Errors are caught and logged but never thrown. The FK to `assistantMsgId` is guaranteed valid because `begin()` already committed the assistant message row.

**Promise.allSettled barrier:** Before `complete()` or `fail()` writes the final status, `awaitPending()` calls `Promise.allSettled(pendingWrites)` to ensure all in-flight satellite writes have settled. This prevents a race where the response ends and the connection is recycled before a satellite insert completes. `allSettled` (not `all`) is used so that a failed satellite write does not prevent finalization.

**Double-finalize guard:** A `finalized` boolean is checked at the top of both `complete()` and `fail()`. Once one has executed, the other is a no-op. This handles edge cases like a timeout error firing after a successful completion.

**Pre-begin safety:** `fail()` checks `this.begun` and returns silently if `begin()` was never called. This allows the error handler to call `persister.fail()` unconditionally without worrying about whether `begin()` itself threw.

### Content Placeholder

The assistant message is created with `content: ""` (empty string). The `messages.content` column is `NOT NULL @db.Text`. Empty string is valid and avoids nullable complexity. The real content is written atomically in `complete()`.

### GET Messages Endpoint

The `handleGetMessages` handler fetches `status` alongside `id` and `depth` from the DB. The value is included in the `ConversationMessage` response. For messages that exist only in Redis (no DB match), status defaults to `"complete"`.

```ts
export interface ConversationMessage {
  id: string;
  chatId: string;
  depth: number;
  role: "user" | "assistant";
  text: string;
  status: "pending" | "streaming" | "complete" | "error" | "cancelled";
  feedback: MessageFeedback | null;
}
```

### Files Changed

| File                                                       | Change                                                                                      |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `db/prisma/schema.prisma`                                  | Add `MessageStatus` enum, `status` field on `Message`                                       |
| `db/prisma/migrations/...add_message_status/migration.sql` | ALTER TABLE + backfill                                                                      |
| `server/src/db/coda/stream-persister.ts`                   | **New.** `StreamPersister` class                                                            |
| `server/src/db/coda/services/message-tree.service.ts`      | Add optional `status` to `AppendMessageInput`                                               |
| `server/src/db/coda/conversation-repository.ts`            | Remove `persistUserMessageAsync`, `persistTurnAsync`. Add `createStreamPersister()` factory |
| `server/src/routes/stream-handler.ts`                      | Remove `handlePostStream`, `PostStreamContext`. Use `StreamPersister` lifecycle             |
| `api/src/types.ts`                                         | Add `status` to `ConversationMessage`                                                       |
| `server/src/routes/message-handlers.ts`                    | Fetch and return `status` in GET response                                                   |
| `server/src/db/coda/turn-assembler.ts`                     | **Deleted.** Logic absorbed into `StreamPersister`                                          |
| `server/src/db/coda/persister.ts`                          | **Deleted.** Replaced by incremental writes                                                 |

## Alternatives Explored

### Why not keep batch-at-end writes?

The batch pattern (`persistTurnAsync`) assembles all satellite data in memory and writes it in a single transaction after the stream ends. This is simpler and uses fewer DB round-trips. However:

- **Data loss on crash:** If the server dies mid-stream (OOM, deployment, crash), the entire assistant turn is lost -- content, tool calls, thoughts, sources. The user sees a blank conversation turn.
- **No status observability:** There is no column to distinguish "this message completed" from "this message never existed." The client cannot show a meaningful error state.
- **Memory pressure:** Multi-round tool-use conversations can accumulate significant data in memory (up to 15 tool rounds with JSON inputs/outputs). Incremental writes free this memory earlier.

The batch pattern is acceptable for short, simple responses. But ows-coda's multi-round tool-use conversations can run for 30-60 seconds with multiple tool calls. The risk window for data loss is significant.

### Why not event sourcing?

An append-only event log (tool_call_started, chunk_received, etc.) with materialized views would provide full auditability. Rejected because:

- **Complexity:** Event sourcing requires a replay mechanism, event schema versioning, and projection logic. The engineering cost is disproportionate to the problem.
- **Query performance:** The GET messages endpoint would need to project events into the current message state on every read, or maintain a separate materialized view.
- **Existing schema:** The current schema (messages + satellite tables) already provides the structure needed. Adding a `status` column is a 1-line migration.

### Why status enum over boolean flags?

An alternative is boolean columns (`is_complete`, `has_error`). The enum was chosen because:

- **Single source of truth:** One column with 5 states vs. two booleans with 4 combinations (including invalid ones like `is_complete=true, has_error=true`).
- **Forward compatibility:** Adding `cancelled` (or future states like `retrying`) is a single enum value addition, not a new column.
- **Query clarity:** `WHERE status = 'error'` reads better than `WHERE is_complete = false AND has_error = true`.

## Cost Analysis

### DB Write Volume

**Before (batch):** 1 transaction at stream end containing 1 message INSERT + N tool call INSERTs + M thought INSERTs + K source INSERTs. Total: 1 round-trip, 1+N+M+K rows.

**After (incremental):** 1 transaction at begin (2 message INSERTs) + 1 status UPDATE (streaming) + N individual tool call INSERTs + M individual thought INSERTs + K individual source INSERTs + 1 final UPDATE (complete). Total: 3+N+M+K round-trips, 2+N+M+K rows written plus 2 updates.

For a typical conversation turn with 3 tool calls, 1 thought, and 2 sources:

| Metric            | Before     | After                            | Delta                            |
| ----------------- | ---------- | -------------------------------- | -------------------------------- |
| DB round-trips    | 1          | 8                                | +7                               |
| Rows inserted     | 7          | 8                                | +1 (assistant placeholder)       |
| Rows updated      | 0          | 2                                | +2 (status: streaming, complete) |
| Transaction scope | 1 large TX | 1 small TX + 7 individual writes | Smaller TX lock duration         |

The extra writes are small (single-row UPDATEs and INSERTs) and fire-and-forget during the stream, so they do not add latency to the SSE response.

### Engineering Effort

- 1 migration (trivial)
- 1 new class (~340 lines including imports and JSDoc)
- 2 files deleted (net code reduction)
- Handler simplification (removed `handlePostStream`, `PostStreamContext`, `RawStreamOutput`)
- Test updates (unit + functional)

Estimated at 1-2 days of engineering time including tests.

## Performance Analysis

### Write Latency Per Satellite Record

Each satellite insert is a single `createMany` call with 1 row. On Aurora MySQL with the connection pool, this typically completes in 1-3ms per write. These are fire-and-forget, so they do not block the SSE stream.

### Total Writes Per Conversation Turn

Worst case with max tool rounds (15 rounds, each with 1 tool call, 1 thought block, and sources):

| Write Type                | Count    | Latency    | Blocking?                  |
| ------------------------- | -------- | ---------- | -------------------------- |
| `begin()` transaction     | 1        | 5-15ms     | Yes (before stream starts) |
| `startStreaming()` update | 1        | 1-3ms      | No                         |
| `addToolCall()`           | up to 15 | 1-3ms each | No                         |
| `addThought()`            | up to 15 | 1-3ms each | No                         |
| `addSources()`            | 1-2      | 1-3ms each | No                         |
| `complete()` update       | 1        | 1-3ms      | Yes (after stream ends)    |
| **Total**                 | **~35**  |            |                            |

In practice, most turns use 1-5 tool calls and 0-2 thought blocks, yielding 5-12 total writes.

### Impact on Stream Throughput

The `begin()` transaction adds 5-15ms of latency before the first SSE event is sent. This is comparable to the previous `persistUserMessageAsync` call (which was fire-and-forget but still consumed a connection). The difference is that `begin()` is awaited, meaning the client sees `message_start` 5-15ms later than before.

During streaming, all satellite writes are fire-and-forget and do not affect chunk delivery latency. The `Promise.allSettled` barrier in `complete()` adds a negligible delay (the writes are usually already settled by the time the LLM finishes its final response).

## Scaling Characteristics

### Write Volume Under Load

Each concurrent stream generates 5-35 DB writes over its lifetime (typically 10-30 seconds). With the current Fargate task count and typical concurrency:

| Scenario | Concurrent Streams | Writes/sec (steady state) | Pool Connections Used |
| -------- | ------------------ | ------------------------- | --------------------- |
| Low load | 5                  | 2-5                       | 5-10                  |
| Normal   | 20                 | 10-25                     | 15-20                 |
| Peak     | 50                 | 25-60                     | 30-50                 |

### Connection Pooling Impact

The default connection pool is 20 connections per task (production: 50). Fire-and-forget writes acquire and release connections quickly (1-3ms). The `begin()` transaction holds a connection for 5-15ms.

At peak load (50 concurrent streams), the pool may briefly saturate. Prisma's built-in connection queue handles this gracefully -- fire-and-forget writes will wait in the queue rather than fail. Since satellite writes are not on the critical path, queuing adds no user-visible latency.

The production pool of 50 connections per task provides adequate headroom. Each stream holds at most 1-2 connections simultaneously (the `begin()` transaction + possibly one fire-and-forget write). Connection churn is higher than before but well within Aurora's connection limits.

## Breakdown Points & Mitigations

### DB failure mid-stream

**Scenario:** The database becomes unreachable after `begin()` succeeds but before `complete()`.

**Impact:** Fire-and-forget satellite writes fail silently (logged). The `complete()` call fails, and the error handler calls `fail()`, which also fails. The assistant message is stuck in `pending` or `streaming`.

**Mitigation:** Fire-and-forget errors are logged at ERROR level for alerting. The message remains in a non-terminal status, which the client displays with a regenerate option. A future stale-message sweep can clean these up:

```sql
UPDATE messages SET status = 'error'
WHERE status IN ('pending', 'streaming')
  AND updated_at < NOW() - INTERVAL 5 MINUTE;
```

### Double-finalize

**Scenario:** Both `complete()` and `fail()` are called (e.g., a timeout fires after successful completion).

**Impact:** Without protection, the message could flip from `complete` to `error`.

**Mitigation:** The `finalized` boolean ensures only the first caller writes. The second is a no-op. This is enforced in the `StreamPersister` class.

### Orphaned pending messages

**Scenario:** Server crashes or is killed (SIGKILL) between `begin()` and `complete()`/`fail()`.

**Impact:** The assistant message is left in `pending` or `streaming` forever.

**Mitigation:** Not addressed in this change. The future stale-message sweep (described above) will handle this. The `cancelled` status is also available for manual intervention. The client shows a regenerate option for non-`complete` messages, so the UX is acceptable.

### Cascade delete

**Scenario:** A message is deleted while satellite records reference it.

**Impact:** Prisma's default referential action is `Restrict` (no cascade). Deleting a message with satellite records would fail.

**Mitigation:** The current schema does not define `onDelete: Cascade` on satellite relations. Message deletion is not currently supported in the API. When it is added, the migration must add `ON DELETE CASCADE` to `message_tool_calls.message_id`, `message_thoughts.message_id`, and `message_sources.message_id`. This is a future concern and out of scope for this change.

### FK safety for fire-and-forget writes

**Scenario:** A fire-and-forget satellite insert tries to reference `assistantMsgId` before it is committed.

**Impact:** FK violation error.

**Mitigation:** This cannot happen. `begin()` commits both messages in a transaction before returning. All fire-and-forget methods are called after `begin()` resolves. The `assertBegun()` guard enforces this at runtime.

## Decision Log

| Date       | Decision                                                 | Rationale                                                                                                                                                               |
| ---------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-03-18 | Use status enum, not boolean flags                       | Single source of truth, forward-compatible with `cancelled`                                                                                                             |
| 2026-03-18 | Fire-and-forget for satellites, awaited for finalization | Satellites are not on the SSE critical path; final status must be durable                                                                                               |
| 2026-03-18 | `Promise.allSettled` barrier before finalization         | Ensures all satellite writes settle before the response ends and the connection is recycled. `allSettled` (not `all`) so a failed satellite does not block finalization |
| 2026-03-18 | No index on `status` column                              | Low cardinality; all queries filter by `chat_id` first using existing composite indexes                                                                                 |
| 2026-03-18 | Model resolution outside the transaction                 | Avoids holding the TX open for a network call to the models table                                                                                                       |
| 2026-03-18 | Empty string for assistant content placeholder           | `content` is `NOT NULL @db.Text`; empty string is valid and avoids nullable complexity                                                                                  |
| 2026-03-18 | Defer cancel endpoint and stale-message sweep            | Keep scope focused; enum is forward-compatible                                                                                                                          |
| 2026-03-18 | `fail()` safe before `begin()`                           | Allows unconditional `persister.fail()` in catch blocks without checking initialization state                                                                           |

## Dependencies

| Dependency                | Type     | Notes                                                                                                |
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| Aurora MySQL (Prisma)     | Runtime  | All writes target the `messages`, `message_tool_calls`, `message_thoughts`, `message_sources` tables |
| `@coda/db`                | Package  | Prisma client, `MessageStatus` enum, `hashIdentity`, `encryptIdentity`                               |
| `@coda/core-api`          | Package  | `ConversationMessage` type (gains `status` field), `SourceLink`, `UsageStats`                        |
| `satellite.service.ts`    | Internal | `insertToolCalls`, `insertThoughts`, `insertSources` methods (unchanged)                             |
| `message-tree.service.ts` | Internal | `appendMessage` (gains optional `status` field), `findLastAssistantMessage`                          |
| `coda-services.ts`        | Internal | `createCodaServices` factory for transaction-scoped services                                         |

No new external dependencies.

## Testing Strategy

### Unit Tests: StreamPersister (`stream-persister.test.ts`)

| Test                                        | What It Verifies                                                                                 |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `begin()` happy path                        | Creates user (status: complete) + assistant (status: pending) in a transaction; returns both IDs |
| `begin()` resolves parentMessageId          | Uses `findLastAssistantMessage` result as user message's parent                                  |
| `begin()` propagates TX errors              | Transaction failure throws (not swallowed)                                                       |
| `startStreaming()`                          | Updates assistant status to `streaming` via fire-and-forget                                      |
| `complete()` with content + usage           | Writes content, status: `complete`, token counts, timing                                         |
| `complete()` with empty content             | Transitions to `complete` even with `content: ""`                                                |
| `fail()` from pending                       | Sets status to `error`                                                                           |
| `fail()` from streaming                     | Sets status to `error`                                                                           |
| `fail()` before `begin()`                   | No-op (does not throw)                                                                           |
| Double-finalize: `complete()` then `fail()` | Second call is a no-op                                                                           |
| Double-finalize: `fail()` then `complete()` | Second call is a no-op                                                                           |
| `addToolCall()`                             | Inserts via `satellite.insertToolCalls` with correct messageId                                   |
| `addToolCall()` error resilience            | Logs error, does not throw                                                                       |
| `addThought()`                              | Inserts with auto-incrementing `stepOrder`                                                       |
| `addThought("")`                            | Skips empty strings                                                                              |
| `addSources()`                              | Inserts via `satellite.insertSources`                                                            |
| `addSources([])`                            | Skips empty arrays                                                                               |

### Unit Tests: message-tree.service

| Test                             | What It Verifies                                    |
| -------------------------------- | --------------------------------------------------- |
| `appendMessage` with `status`    | `status` included in `prisma.message.create()` data |
| `appendMessage` without `status` | DB default applies (field omitted from create data) |

### Unit Tests: conversation-repository

| Test                                       | What It Verifies                                          |
| ------------------------------------------ | --------------------------------------------------------- |
| `createStreamPersister()` returns null     | When `codaServices`, `hmacSecret`, or `aesKey` is missing |
| `createStreamPersister()` returns instance | When all credentials are available                        |

### Functional Tests (`conversation-lifecycle.test.ts`)

| Test                               | What It Verifies                                                    |
| ---------------------------------- | ------------------------------------------------------------------- |
| Messages persisted after streaming | Both messages have `status: 'complete'`                             |
| GET messages includes status       | `status` field present in response                                  |
| Incomplete message status          | Direct DB creation without `complete()` returns `status: 'pending'` |

## Rollout Plan

### Step 1: Migration (before deployment)

The migration runs automatically via the Docker `migrate` service (which calls `prisma migrate deploy` before the server container starts). The migration:

1. Adds the `status` column with `DEFAULT 'pending'`
2. Backfills all existing rows to `'complete'`

This is backward-compatible: old code ignores the new column; new code reads it.

### Step 2: Deploy server

The new code reads and writes `status`. The deployment can be a standard rolling update:

- New tasks use `StreamPersister` (incremental writes)
- Old tasks (if briefly coexisting during rollout) use the old batch pattern and never write `status`, so messages they create will have the DB default `'pending'`. The backfill at next migration or the stale-message sweep will handle these, but in practice the rolling update completes in under a minute, making this a negligible window.

### Step 3: Client compatibility

The `ConversationMessage` type gains a `status` field. The client must be deployed after the server so that the API response includes the field. The client treats non-`complete` statuses as error states with a regenerate option.

**Backward compatibility of SSE events:** The `StreamDoneEvent` (`{ done: true, messageId, userMessageId }`) is unchanged. Existing clients that do not read `status` from GET messages will continue to work.

### Rollback

If issues are found:

1. Roll back the server to the previous version. Old code ignores the `status` column.
2. The migration does not need to be rolled back -- the column is harmless to old code.
3. Messages created by the new code with incremental writes will have complete satellite records. The only difference is they have a `status` value, which old code ignores.

## Open Questions

1. **Stale message sweep timing:** Should the sweep run on a schedule (cron) or at server startup? Startup is simpler but does not cover long-running server instances. A periodic sweep (every 5 minutes) is more robust but requires a leader-election mechanism to avoid multiple Fargate tasks running it simultaneously.

2. **Cancel endpoint priority:** The `cancelled` status is in the enum but the endpoint is deferred. Should it be prioritized if users report frustration with long-running streams they cannot stop?

3. **Satellite write batching:** Currently each tool call is persisted individually. If a single LLM round produces multiple tool uses (common with parallel tool execution), should they be batched into a single `insertToolCalls` call? This would reduce round-trips from N to 1 per round but requires buffering in the `onToolExecution` callback.
