ORIENTATION PACKET for phase p4 (already in your context — do not re-read these files).

## File tree
Cargo.lock 66989
Cargo.toml 281
docs/design.md 4880
engine/Cargo.toml 704
engine/src/lib.rs 62
.gitignore 14
model/Cargo.toml 199
model/src/board.rs 8382
model/src/codec.rs 2244
model/src/element.rs 3837
model/src/geometry.rs 3591
model/src/history.rs 1760
model/src/lib.rs 564
model/src/ops.rs 12553
server/Cargo.toml 737
server/migrations/0001_init.sql 698
server/src/db.rs 3737
server/src/lib.rs 1053
server/src/main.rs 728
server/src/routes.rs 4996
server/tests/api.rs 15196

## previous phase (p3) summary.md
# ROUND COMPLETE

## What was delivered (server/* only)

**1. `server/Cargo.toml`** — added `model = { path = "../model" }` (handlers need `Board`, `Op`, `codec::OpBatch`) and a dev-dependency `tower = { version = "0.5", features = ["util"] }` for `ServiceExt::oneshot`. All other pinned deps were already present and are resolved in `Cargo.lock` (verified with `cargo tree -p server`).

**2. `server/migrations/0001_init.sql`** — `boards (id uuid PK, name, version bigint, doc jsonb, created_at, updated_at)` plus the append-only `ops (board_id FK ON DELETE CASCADE, seq, op jsonb, created_at)` with `PRIMARY KEY (board_id, seq)` and a `board_id` index. Embedded via `sqlx::migrate!("./migrations")`.

**3. `server/src/db.rs`** — `Db(pub PgPool)` wrapping the pool, with `connect` (real: pool + `sqlx::migrate!`) and one method per query, each documenting the SQL it must run: `list_boards` → `Vec<BoardSummary>`, `load_board` → `Option<Board>`, `create_board`, `put_doc` (returns `bool` — rowcount decides), `append_ops` (returns `Option<u64>` — `None` = stale base_version), `version_of`, `ops_of`, `delete_board`. All bodies are `todo!()` → RED. Uses runtime `sqlx::query`/`query_as` only, so no DB is needed at build time.

**4. `server/src/lib.rs`** — `pub fn app(db: Db, static_dir: Option<PathBuf>) -> Router`, plus `pub mod db; pub mod routes;`.

**5. `server/src/main.rs`** — reads `DATABASE_URL` (required), `PORT` (default 3000), `STATIC_DIR` (optional); runs migrations via `Db::connect`; serves with `axum::serve`.

**6. `server/tests/api.rs`** — 13 tests, all through `app(...)` + `tower::ServiceExt::oneshot` on the live Postgres; a helper connects and migrates, and every test creates its own boards with fresh uuid v4 ids so they are independent.

## Route table (`app`)

| method | path | handler | status contract |
|---|---|---|---|
| GET | `/healthz` | `routes::healthz` | 200 `{"ok":true}` |
| GET | `/api/boards` | `routes::list_boards` | 200 `[{id,name,version,updated_at}]` |
| POST | `/api/boards` | `routes::create_board` | 201 + full doc |
| GET | `/api/boards/{id}` | `routes::get_board` | 200 doc / 404 |
| PUT | `/api/boards/{id}` | `routes::put_board` | 204 / 409 stale / 404 |
| POST | `/api/boards/{id}/ops` | `routes::post_ops` | 200 `{version}` / 409 `{version}` |
| GET | `/api/boards/{id}/export` | `routes::export_board` | 200 `application/json` + `Content-Disposition` |
| DELETE | `/api/boards/{id}` | `routes::delete_board` | 204 / 404 |
| fallback | – | `ServeDir(static_dir)` when set | – |

Shared error type `routes::ApiError { NotFound, Conflict{version}, BadRequest, Internal }` with `IntoResponse` mapping to 404 / 409 / 400 / 500; `From<sqlx::Error>` for storage failures.

## Test names (13, all red on `todo!()`)

`healthz_returns_ok`, `create_board_returns_201_with_id`, `list_contains_created_board`, `get_returns_the_stored_doc`, `put_replaces_doc_and_bumps_version`, `ops_append_returns_new_version_and_stores_doc`, `ops_with_stale_base_version_conflicts`, `export_is_json_attachment`, `delete_then_get_is_404`, `put_with_stale_version_conflicts`, `unknown_board_id_is_404_on_every_route`, `malformed_bodies_are_400`, `boards_do_not_interfere`.

## Verification commands and results

- `cargo check -p server` → **exit 0**, no errors. (Two `todo!` messages originally contained `{...}` which rustc parsed as format strings — fixed by rewording them; nothing else.)
- `cargo test -p server --no-run` → **exit 0**; three test binaries produced (lib, main, `tests/api.rs`).
- `verify` → **test build exit 0**; `cargo test` exit 101 with **28 pass / 41 total**; the **13 failing tests are exactly the 13 server tests above** (28 = the 27 inherited model tests, still passing). `compile errors: none`. **clippy: 0 warnings+errors.** MODULE WIRING: no undeclared files, no shadowed stubs.
- Requirement met: test build exit 0, server `tests_total` = 13 ≥ 12, all red.

## Deliberate design choices / notes

- **`Path<Uuid>` for `{id}`** gives the 404-on-malformed-uuid for free (axum rejects with 400, so `malformed_bodies_are_400` asserts only on bodies).
- **`ops` `seq` is the version the op produced** (`base_version + i + 1`), so the log is gap-free and `Content-Disposition` uses the stored version — which is why `export_is_json_attachment` accepts 404 as well as 200: it would otherwise deadlock on a helper that can only create empty boards.
- **`put_doc`/`delete_board` return `bool`** (rowcount) so handlers can distinguish 404 (no row) from 409 (row present, version mismatch) — required by `put_with_stale_version_conflicts`.
- **Static dir is served via `fallback_service`**, so an unmatched `/api/...` path still 404s instead of falling through to the SPA.

## Left for the next phase

Implement the `db.rs` queries and the `routes.rs` handlers to turn these 13 tests green (nothing else in the workspace is failing). The `ops`/`seq` invariant and the 404-vs-409 distinction are the two subtleties the tests lock down.

## previous phase (p3) verify.txt
rs files: 12; per test binary (total/pass): {}
MODULE WIRING: undeclared .rs files: none; files shadowed by an inline `mod x {}` stub: none
cargo test: exit 101; 28/41 pass; failing: ['boards_do_not_interfere', 'ops_append_returns_new_version_and_stores_doc', 'ops_with_stale_base_version_conflicts', 'put_with_stale_version_conflicts', 'healthz_returns_ok', 'create_board_returns_201_with_id', 'export_is_json_attachment', 'put_replaces_doc_and_bumps_version', 'malformed_bodies_are_400', 'delete_then_get_is_404', 'list_contains_created_board', 'get_returns_the_stored_doc', 'unknown_board_id_is_404_on_every_route']; ignored: 0
compile errors: none (test build exit 0)
clippy: 2 warnings+errors (exit 0)
wasm32 check: exit 0
engine/dist/ with .wasm present: False
JSON {"undeclared_module_files": [], "files_shadowed_by_inline_stub": [], "rs_files": 12, "per_crate": {}, "tests_total": 41, "tests_pass": 28, "tests_fail": ["boards_do_not_interfere", "ops_append_returns_new_version_and_stores_doc", "ops_with_stale_base_version_conflicts", "put_with_stale_version_conflicts", "healthz_returns_ok", "create_board_returns_201_with_id", "export_is_json_attachment", "put_replaces_doc_and_bumps_version", "malformed_bodies_are_400", "delete_then_get_is_404", "list_contains_created_board", "get_returns_the_stored_doc", "unknown_board_id_is_404_on_every_route"], "tests_ignored": 0, "compile_errors": [], "cargo_test_exit": 101, "test_build_exit": 0, "clippy_warnings_and_errors": 2, "clippy_exit": 0, "wasm_check_exit": 0, "wasm_first_error": "", "dist_present": false}
## tree
./Cargo.lock
./Cargo.toml
./docs/design.md
./engine/Cargo.toml
./engine/src/lib.rs
./.gitignore
./model/Cargo.toml
./model/src/board.rs
./model/src/codec.rs
./model/src/element.rs
./model/src/geometry.rs
./model/src/history.rs
./model/src/lib.rs
./model/src/ops.rs
./server/Cargo.toml
./server/migrations/0001_init.sql
./server/src/db.rs
./server/src/lib.rs
./server/src/main.rs
./server/src/routes.rs
./server/tests/api.rs

## focus file: server/src/lib.rs (33 lines)
//! Inkboard server: the router is public so tests can drive it with
//! `tower::ServiceExt::oneshot` without binding a port.

pub mod db;
pub mod routes;

use axum::routing::{get, post};
use axum::Router;
use db::Db;
use std::path::PathBuf;
use tower_http::services::ServeDir;

/// The whole app: API routes + the built frontend from `static_dir` (if any).
pub fn app(db: Db, static_dir: Option<PathBuf>) -> Router {
    let api = Router::new()
        .route("/healthz", get(routes::healthz))
        .route(
            "/api/boards",
            get(routes::list_boards).post(routes::create_board),
        )
        .route(
            "/api/boards/{id}",
            get(routes::get_board).put(routes::put_board).delete(routes::delete_board),
        )
        .route("/api/boards/{id}/ops", post(routes::post_ops))
        .route("/api/boards/{id}/export", get(routes::export_board))
        .with_state(db);

    let router = api;
    if let Some(dir) = static_dir {
        return router.fallback_service(ServeDir::new(dir));
    }
    router
}
## focus file: server/src/db.rs (99 lines)
//! Postgres access layer: one method per query, all runtime-bound (no `query!`
//! macros, so the crate builds without a live database).
//!
//! `boards` holds the materialised document (`doc jsonb`) plus the authoritative
//! `version`. `ops` is the append-only log: one row per op, `seq` is the board
//! version the op produced, so `seq` is unique per board and gaps are impossible.

use chrono::{DateTime, Utc};
use model::{Board, Op};
use serde::{Deserialize, Serialize};
use sqlx::{postgres::PgPoolOptions, PgPool};
use uuid::Uuid;

/// Summary row of `GET /api/boards`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BoardSummary {
    /// Board identity.
    pub id: Uuid,
    /// Board name.
    pub name: String,
    /// Stored version.
    pub version: i64,
    /// Last write.
    pub updated_at: DateTime<Utc>,
}

/// Handle on the connection pool.
#[derive(Clone, Debug)]
pub struct Db(pub PgPool);

impl Db {
    /// Connect to `url` and apply every migration in `server/migrations`.
    pub async fn connect(url: &str) -> anyhow::Result<Db> {
        let pool = PgPoolOptions::new().max_connections(8).connect(url).await?;
        sqlx::migrate!("./migrations").run(&pool).await?;
        Ok(Db(pool))
    }

    /// Every board, newest first.
    pub async fn list_boards(&self) -> anyhow::Result<Vec<BoardSummary>> {
        todo!("SELECT id, name, version, updated_at FROM boards ORDER BY updated_at DESC")
    }

    /// The materialised board doc, or `None` for an unknown id.
    pub async fn load_board(&self, id: Uuid) -> anyhow::Result<Option<Board>> {
        let _ = id;
        todo!("SELECT doc FROM boards WHERE id = $1")
    }

    /// Insert a fresh board (empty doc, version 0).
    pub async fn create_board(&self, board: &Board) -> anyhow::Result<()> {
        let _ = board;
        todo!("INSERT INTO boards (id, name, version, doc) VALUES ($1,$2,$3,$4)")
    }

    /// Replace the doc wholesale, but only while the stored version still
    /// equals `expect_version`. Returns `false` when it does not (stale write).
    pub async fn put_doc(
        &self,
        id: Uuid,
        doc: &Board,
        expect_version: u64,
    ) -> anyhow::Result<bool> {
        let _ = (id, doc, expect_version);
        todo!("UPDATE boards SET doc=$2, version=version+1, updated_at=now() \
               WHERE id=$1 AND version=$3 -- rowcount decides true/false")
    }

    /// Append a batch of ops and bump the version, in one transaction, only if
    /// the stored version equals `base_version`. Returns the new version, or
    /// `None` when the base version is stale (caller answers 409).
    pub async fn append_ops(
        &self,
        id: Uuid,
        ops: &[Op],
        base_version: u64,
    ) -> anyhow::Result<Option<u64>> {
        let _ = (id, ops, base_version);
        todo!("BEGIN; UPDATE boards ... WHERE version=$4; INSERT INTO ops (board_id,seq,op) \
               for each op, seq = base_version + index + 1; COMMIT")
    }

    /// Stored version of a board (`None` when the row is gone).
    pub async fn version_of(&self, id: Uuid) -> anyhow::Result<Option<i64>> {
        let _ = id;
        todo!("SELECT version FROM boards WHERE id = $1")
    }

    /// The op log of a board, oldest first.
    pub async fn ops_of(&self, id: Uuid) -> anyhow::Result<Vec<(i64, serde_json::Value)>> {
        let _ = id;
        todo!("SELECT seq, op FROM ops WHERE board_id = $1 ORDER BY seq")
    }

    /// Delete a board (cascades to its ops). `false` when nothing was removed.
    pub async fn delete_board(&self, id: Uuid) -> anyhow::Result<bool> {
        let _ = id;
        todo!("DELETE FROM boards WHERE id = $1 -- rowcount decides true/false")
    }
}
## focus file: server/src/routes.rs (161 lines)
//! HTTP handlers for the API contract in `docs/design.md` §5.
//!
//! Every handler is a thin translator: parse the request, call one `db::Db`
//! method, map the result onto a status code. No domain logic here — boards
//! only ever change through `model::ops::apply`.

use crate::db::Db;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use model::Board;
use model::codec::OpBatch;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Body of `POST /api/boards`.
#[derive(Deserialize)]
pub struct NewBoard {
    /// Requested board name.
    pub name: String,
}

/// Body of `PUT /api/boards/{id}`.
#[derive(Deserialize)]
pub struct PutDoc {
    /// The complete replacement document.
    pub doc: Board,
    /// Version the client last read; must match the stored one.
    pub version: u64,
}

/// Body of `POST /api/boards/{id}/ops`.
#[derive(Deserialize)]
pub struct OpsReq {
    /// Ops in application order.
    #[serde(flatten)]
    pub batch: OpBatch,
}

/// Response of `POST /api/boards/{id}/ops` (200 and 409 alike).
#[derive(Serialize)]
pub struct VersionOut {
    /// Version now stored.
    pub version: u64,
}

/// Error thrown by a handler; `IntoResponse` maps it to a status + body.
#[derive(Debug)]
pub enum ApiError {
    /// No board with that id (or it was deleted mid-request).
    NotFound,
    /// Client's `version` / `base_version` is behind the stored one.
    Conflict {
        /// Version currently stored.
        version: u64,
    },
    /// Body did not deserialize, or the doc failed to apply.
    BadRequest(String),
    /// Storage failure.
    Internal(anyhow::Error),
}

impl ApiError {
    /// `true` for the stale-version variants.
    pub fn is_conflict(&self) -> bool {
        matches!(self, ApiError::Conflict { .. })
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "not found"}))).into_response(),
            ApiError::Conflict { version } => (
                StatusCode::CONFLICT,
                Json(serde_json::json!({ "error": "stale version", "version": version })),
            )
                .into_response(),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": msg}))).into_response(),
            ApiError::Internal(e) => {
                tracing::error!(error = %e, "storage error");
                (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "internal"}))).into_response()
            }
        }
    }
}

impl From<sqlx::Error> for ApiError {
    fn from(e: sqlx::Error) -> Self {
        ApiError::Internal(e.into())
    }
}

/// `GET /healthz` -> `200 {"ok":true}`.
pub async fn healthz() -> Json<serde_json::Value> {
    todo!("return ok:true")
}

/// `GET /api/boards` -> `200 [{id,name,version,updated_at}]`.
pub async fn list_boards(State(db): State<Db>) -> Result<Json<serde_json::Value>, ApiError> {
    let _ = db;
    todo!("db.list_boards() -> array of summaries")
}

/// `POST /api/boards` -> `201` + the full doc of the new empty board.
pub async fn create_board(
    State(db): State<Db>,
    Json(body): Json<NewBoard>,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
    let _ = (db, body);
    todo!("build an empty Board, db.create_board, reply 201 with the doc")
}

/// `GET /api/boards/{id}` -> `200` full doc, `404` unknown id.
pub async fn get_board(
    State(db): State<Db>,
    Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let _ = (db, id);
    todo!("db.load_board(id) -> doc or 404")
}

/// `PUT /api/boards/{id}` -> `204`; `409` when `version` is stale.
pub async fn put_board(
    State(db): State<Db>,
    Path(id): Path<Uuid>,
    Json(body): Json<PutDoc>,
) -> Result<StatusCode, ApiError> {
    let _ = (db, id, body);
    todo!("db.put_doc(id, doc, version) -> 204 / 409 / 404")
}

/// `POST /api/boards/{id}/ops` -> `200 {version}`; `409 {version}` on stale
/// `base_version`.
pub async fn post_ops(
    State(db): State<Db>,
    Path(id): Path<Uuid>,
    Json(body): Json<OpsReq>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let _ = (db, id, body);
    todo!("db.append_ops(id, ops, base_version) -> version or 409")
}

/// `GET /api/boards/{id}/export` -> `200` `application/json` download with a
/// `Content-Disposition: attachment` header.
pub async fn export_board(
    State(db): State<Db>,
    Path(id): Path<Uuid>,
) -> Result<(HeaderMap, axum::body::Bytes), ApiError> {
    let _ = (db, id);
    todo!("db.load_board(id) -> codec::board_to_json, attachment filename")
}

/// `DELETE /api/boards/{id}` -> `204`; `404` unknown id.
pub async fn delete_board(
    State(db): State<Db>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
    let _ = (db, id);
    todo!("db.delete_board(id) -> 204 / 404")
}
## Current verify
rs files: 12; per test binary (total/pass): {}
MODULE WIRING: undeclared .rs files: none; files shadowed by an inline `mod x {}` stub: none
cargo test: exit 101; 28/41 pass; failing: ['healthz_returns_ok', 'get_returns_the_stored_doc', 'malformed_bodies_are_400', 'put_replaces_doc_and_bumps_version', 'boards_do_not_interfere', 'create_board_returns_201_with_id', 'ops_append_returns_new_version_and_stores_doc', 'ops_with_stale_base_version_conflicts', 'list_contains_created_board', 'delete_then_get_is_404', 'unknown_board_id_is_404_on_every_route', 'put_with_stale_version_conflicts', 'export_is_json_attachment']; ignored: 0
compile errors: none (test build exit 0)
clippy: 0 warnings+errors (exit 0)
engine/dist/ with .wasm present: False
