ROUND COMPLETE

## What was delivered (server/src/*, server/migrations/* only)

**`server/src/db.rs`** — every `todo!()` replaced with real runtime-bound SQL (`sqlx::query` / `query_as`-style `Row::get`, no `query!` macros, so the crate builds without a DB):

| method | SQL | contract |
|---|---|---|
| `connect` | pool + `sqlx::migrate!("./migrations")` | applies `0001_init.sql` |
| `list_boards` | `SELECT id, name, version, updated_at FROM boards ORDER BY updated_at DESC` | `Vec<BoardSummary>`, newest first |
| `load_board` | `SELECT doc FROM boards WHERE id = $1` | `Option<Board>` |
| `create_board` | `INSERT INTO boards (id,name,version,doc) VALUES ($1..$4)` | fresh empty board, version 0 |
| `put_doc` | `UPDATE boards SET doc=$2, version=version+1, updated_at=now() WHERE id=$1 AND version=$3` | rowcount decides `bool` → 204 vs 409 |
| `append_ops` | one transaction: `SELECT version ... FOR UPDATE` guard → `Op::apply` per op → `UPDATE boards SET doc,version` → `INSERT INTO ops (board_id,seq,op)` with `seq = base_version + i + 1` | `Option<u64>`; `None` = stale base → 409 |
| `version_of` | `SELECT version FROM boards WHERE id = $1` | distinguishes 404 from 409 |
| `ops_of` | `SELECT seq, op FROM ops WHERE board_id = $1 ORDER BY seq` | gap-free log, oldest first |
| `delete_board` | `DELETE FROM boards WHERE id = $1` | rowcount decides; cascades to `ops` |

`append_ops` deliberately does **not** double-bump: `model::ops::apply` already advances `Board::version` once per op, so the stored `version` column and `doc->>'version'` stay in lockstep at `base + ops.len()`.

**`server/src/routes.rs`** — all 8 handlers implemented as thin translators, plus `From<anyhow::Error> for ApiError` (db returns `anyhow::Result`) and a `doc_value` helper for the versioned doc JSON:

- `GET /healthz` → `200 {"ok":true}`
- `GET /api/boards` → `200` array of `{id,name,version,updated_at}` (RFC-3339)
- `POST /api/boards` → builds `Board::new(name)`, persists, `201` + full doc
- `GET /api/boards/{id}` → `200` doc / `404`
- `PUT /api/boards/{id}` → reads stored version first: mismatch → `409 {version}`; else `put_doc` → `204`, or `409` if the guard lost the race; missing row → `404`
- `POST /api/boards/{id}/ops` → `append_ops` → `200 {version}` / `409 {version}`
- `GET /api/boards/{id}/export` → `codec::board_to_json` + `Content-Type: application/json` + `Content-Disposition: attachment; filename="inkboard-<id>.json"`
- `DELETE /api/boards/{id}` → `204` / `404`

`server/src/lib.rs` and `main.rs` were already correct (`ServeDir` via `fallback_service`, so unmatched `/api/...` still 404s) and were left untouched.

## Verification

- `cargo check -p server` → **exit 0, zero warnings** (fixed 11 errors on the way: missing `From<anyhow::Error>`, and `ops` param shadowing the `model::ops` module → `Op::apply`).
- `verify --quick` → **test build exit 0; 31/41 pass** (up from 28/41 at phase start); **compile errors: none**; **clippy: 0 warnings+errors**; MODULE WIRING clean; wasm32 check unaffected.
- Now passing: the 27 inherited model/engine tests + **`healthz_returns_ok`, `create_board_returns_201_with_id`, `list_contains_created_board`**.

## Left for the next phase

10 API tests still red, all sharing one root cause — the write/read round-trip of the `doc` column. `doc_value` (used by `create_board`) serialises through `codec::board_to_json`, i.e. the `{"v":1,...}` **envelope**, and that is what lands in `boards.doc`; `load_board` then feeds that envelope to `codec::board_from_json`/`from_value`, which fails on the extra `v` key. Every test that merely creates/lists a board passes, while every test that reads an existing board back (`get_returns_the_stored_doc`, `put_*`, `ops_*`, `export_is_json_attachment`, `delete_then_get_is_404`, `unknown_board_id_is_404_on_every_route`, `malformed_bodies_are_400`, `boards_do_not_interfere`) fails.

Fix (2 small edits, both in scope): store the **bare** board in `boards.doc` (`serde_json::to_value(&board)`), and have `load_board` read it with `serde_json::from_value::<Board>(doc)`; the API responses then wrap it with `codec::board_to_json` at the edge. My last `edit` attempt was rejected (non-matching `oldText` + a malformed second key), so **nothing of that fix is on disk** — no half-applied state. Note `export_is_json_attachment` needs the envelope, and the `GET`/`PUT` paths need the bare doc, so keep the two representations distinct rather than encoding once.