# Foundation Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace the placeholder single-package scaffold with a role-named Cargo workspace running a tested, single-tenant axum server that exposes health probes and a config-driven OpenAPI document.

**Architecture:** A Cargo virtual workspace with role-named crates (`domain`, `db`, `api`, `server`). `domain` holds pure types (no I/O); `db` owns all SQL behind a `Db` handle; `api` builds the axum router, handlers, and OpenAPI doc; `server` is the binary that loads config (clap derive, env+args), connects the database, and serves. The product name is never hardcoded — it comes from config and flows into the OpenAPI title.

**Tech Stack:** Rust 2024, tokio, axum 0.8, sqlx 0.8 (PostgreSQL), clap 4 (derive), utoipa 5 (code-first OpenAPI), tracing. Tests use `#[sqlx::test]` + `tower::ServiceExt::oneshot` and a TCP smoke test with reqwest.

---

## Prerequisites (read once before starting)

- **Rust** stable ≥ 1.85 (edition 2024) and the **nightly** toolchain for formatting (`cargo +nightly fmt`).
- **Docker** (for a local PostgreSQL during tests) — or any reachable PostgreSQL where the connecting role may `CREATE DATABASE` (required by `#[sqlx::test]`, which provisions a temp DB per test).
- **`just`** (optional, for the task runner added in Task 7).
- **Verify crate versions before pinning.** Versions below are expected-current for 2026; confirm the latest compatible release with the `cratesio-mcp` tools and adjust if needed. Add/update dependencies with `cargo-mcp` rather than hand-editing where practical.
- Set `DATABASE_URL` before running DB-backed tests, e.g. `export DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev` (Task 7 ships a `docker-compose.yml` and `.env.example`).

## File Structure

```
Cargo.toml                      virtual workspace (no [package])
crates/
  domain/
    Cargo.toml
    src/lib.rs                  re-exports
    src/id.rs                   newtype IDs (OrgId)
  db/
    Cargo.toml
    src/lib.rs                  Db handle: connect/from_pool/pool/ping
    tests/ping.rs               #[sqlx::test] ping
  api/
    Cargo.toml
    src/lib.rs                  AppState, build_app
    src/health.rs               /health/live, /health/ready
    src/openapi.rs              ApiDoc + /api-docs/openapi.json
    tests/health.rs             oneshot tests
  server/
    Cargo.toml
    src/lib.rs                  Config re-export, run(), serve()
    src/config.rs               clap-derive Config
    src/main.rs                 binary entrypoint
    tests/config.rs             arg/env parsing
    tests/serve.rs              TCP smoke test
docker-compose.yml              local Postgres
.env.example
justfile
```

Removed: the root `[package]` and `src/main.rs` (the `biggus-dickus` hello-world scaffold).

---

## Task 1: Workspace skeleton (dissolve the placeholder package)

**Files:**
- Modify: `Cargo.toml` (replace entire contents)
- Delete: `src/main.rs` (and the now-empty `src/`)
- Create: `crates/domain/Cargo.toml`, `crates/domain/src/lib.rs`
- Create: `crates/db/Cargo.toml`, `crates/db/src/lib.rs`
- Create: `crates/api/Cargo.toml`, `crates/api/src/lib.rs`
- Create: `crates/server/Cargo.toml`, `crates/server/src/main.rs`

- [ ] **Step 1: Replace the root `Cargo.toml` with a virtual workspace**

Overwrite `Cargo.toml` with exactly:

```toml
[workspace]
resolver = "3"
members = ["crates/domain", "crates/db", "crates/api", "crates/server"]

[workspace.package]
edition = "2024"
rust-version = "1.85"

[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "macros"] }
uuid = { version = "1", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive", "env"] }
utoipa = { version = "5", features = ["uuid"] }
anyhow = "1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
```

- [ ] **Step 2: Remove the placeholder binary**

Run: `git rm src/main.rs`
Expected: `rm 'src/main.rs'`. The `src/` directory is now empty (removed).

- [ ] **Step 3: Create the `domain` crate (empty for now)**

`crates/domain/Cargo.toml`:

```toml
[package]
name = "domain"
version = "0.0.0"
edition.workspace = true
rust-version.workspace = true

[dependencies]
uuid.workspace = true
serde.workspace = true
```

`crates/domain/src/lib.rs`:

```rust
//! Core domain types and invariants. No I/O dependencies.
```

- [ ] **Step 4: Create the `db` crate (empty for now)**

`crates/db/Cargo.toml`:

```toml
[package]
name = "db"
version = "0.0.0"
edition.workspace = true
rust-version.workspace = true

[dependencies]
sqlx.workspace = true
thiserror.workspace = true

[dev-dependencies]
tokio.workspace = true
```

`crates/db/src/lib.rs`:

```rust
//! Database access. All SQL lives in this crate.
```

- [ ] **Step 5: Create the `api` crate (empty for now)**

`crates/api/Cargo.toml`:

```toml
[package]
name = "api"
version = "0.0.0"
edition.workspace = true
rust-version.workspace = true

[dependencies]
axum.workspace = true
serde.workspace = true
utoipa.workspace = true
db = { path = "../db" }

[dev-dependencies]
tokio.workspace = true
tower.workspace = true
http-body-util.workspace = true
serde_json.workspace = true
sqlx.workspace = true
```

`crates/api/src/lib.rs`:

```rust
//! HTTP API: router, handlers, and OpenAPI document.
```

- [ ] **Step 6: Create the `server` crate with a placeholder main**

`crates/server/Cargo.toml`:

```toml
[package]
name = "server"
version = "0.0.0"
edition.workspace = true
rust-version.workspace = true

[lib]
path = "src/lib.rs"

[[bin]]
name = "server"
path = "src/main.rs"

[dependencies]
tokio.workspace = true
axum.workspace = true
clap.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
api = { path = "../api" }
db = { path = "../db" }

[dev-dependencies]
reqwest.workspace = true
serde_json.workspace = true
api = { path = "../api" }
db = { path = "../db" }
sqlx.workspace = true
```

`crates/server/src/lib.rs`:

```rust
//! Server wiring: configuration and startup.
```

`crates/server/src/main.rs`:

```rust
fn main() {
    println!("placeholder");
}
```

- [ ] **Step 7: Verify the workspace builds**

Run: `cargo build --workspace`
Expected: compiles successfully; four crates (`domain`, `db`, `api`, `server`) build with warnings only about unused placeholders being acceptable.

- [ ] **Step 8: Commit**

```bash
git add -A
git commit -m "chore: replace placeholder package with role-named workspace"
```

---

## Task 2: `domain` — newtype IDs

**Files:**
- Create: `crates/domain/src/id.rs`
- Modify: `crates/domain/src/lib.rs`

- [ ] **Step 1: Write the failing test**

Create `crates/domain/src/id.rs`:

```rust
use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Identifier for an organization (tenant).
///
/// A newtype over [`Uuid`] so it can never be confused with another entity's id.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct OrgId(Uuid);

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_and_displays_round_trip() {
        let text = "550e8400-e29b-41d4-a716-446655440000";
        let id: OrgId = text.parse().expect("valid uuid should parse");
        assert_eq!(id.to_string(), text);
    }

    #[test]
    fn rejects_invalid_uuid() {
        assert!("not-a-uuid".parse::<OrgId>().is_err());
    }
}
```

Wire it into `crates/domain/src/lib.rs`:

```rust
//! Core domain types and invariants. No I/O dependencies.

mod id;

pub use id::OrgId;
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cargo test -p domain`
Expected: FAIL — compile error, `FromStr`/`Display` not implemented for `OrgId` (and `parse` unavailable).

- [ ] **Step 3: Implement the newtype API**

Append to `crates/domain/src/id.rs`, immediately after the `pub struct OrgId(Uuid);` line (before the `#[cfg(test)]` module):

```rust
impl OrgId {
    /// Generate a fresh random id.
    pub fn new() -> Self {
        Self(Uuid::new_v4())
    }

    /// Wrap an existing [`Uuid`].
    pub fn from_uuid(uuid: Uuid) -> Self {
        Self(uuid)
    }

    /// The underlying [`Uuid`].
    pub fn as_uuid(&self) -> Uuid {
        self.0
    }
}

impl Default for OrgId {
    fn default() -> Self {
        Self::new()
    }
}

impl fmt::Display for OrgId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl FromStr for OrgId {
    type Err = uuid::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Self(Uuid::parse_str(s)?))
    }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `cargo test -p domain`
Expected: PASS — 2 tests (`parses_and_displays_round_trip`, `rejects_invalid_uuid`).

- [ ] **Step 5: Commit**

```bash
git add crates/domain
git commit -m "feat(domain): add OrgId newtype"
```

---

## Task 3: `server` — configuration via clap derive

**Files:**
- Create: `crates/server/src/config.rs`
- Modify: `crates/server/src/lib.rs`
- Test: `crates/server/tests/config.rs`

- [ ] **Step 1: Write the failing test**

Create `crates/server/tests/config.rs`:

```rust
use clap::Parser;
use server::Config;

#[test]
fn parses_from_args_with_defaults() {
    let cfg = Config::try_parse_from([
        "server",
        "--database-url",
        "postgres://localhost/test",
    ])
    .expect("should parse");

    assert_eq!(cfg.database_url, "postgres://localhost/test");
    assert_eq!(cfg.bind_addr, "0.0.0.0:8080");
    assert_eq!(cfg.app_name, "Collection Management System");
}

#[test]
fn database_url_is_required() {
    assert!(Config::try_parse_from(["server"]).is_err());
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cargo test -p server --test config`
Expected: FAIL — `server::Config` does not exist.

- [ ] **Step 3: Implement the config and export it**

Create `crates/server/src/config.rs`:

```rust
use clap::Parser;

/// Runtime configuration, sourced from CLI arguments and environment variables.
#[derive(Debug, Clone, Parser)]
#[command(version, about = "Collection management system server")]
pub struct Config {
    /// PostgreSQL connection string.
    #[arg(long, env = "DATABASE_URL")]
    pub database_url: String,

    /// Address to bind the HTTP server to.
    #[arg(long, env = "BIND_ADDR", default_value = "0.0.0.0:8080")]
    pub bind_addr: String,

    /// User-facing application name (OpenAPI title, page title, …).
    ///
    /// Defaults to a neutral name; set this to the real product name at deploy
    /// time. The product name must never be hardcoded in source.
    #[arg(long, env = "APP_NAME", default_value = "Collection Management System")]
    pub app_name: String,
}
```

Replace `crates/server/src/lib.rs` with:

```rust
//! Server wiring: configuration and startup.

mod config;

pub use config::Config;
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `cargo test -p server --test config`
Expected: PASS — 2 tests.

- [ ] **Step 5: Commit**

```bash
git add crates/server
git commit -m "feat(server): add clap-derive Config (args + env)"
```

---

## Task 4: `db` — connection pool and readiness ping

**Files:**
- Modify: `crates/db/src/lib.rs`
- Test: `crates/db/tests/ping.rs`

- [ ] **Step 1: Write the failing test**

Create `crates/db/tests/ping.rs`:

```rust
use db::Db;
use sqlx::PgPool;

#[sqlx::test]
async fn ping_succeeds(pool: PgPool) {
    let db = Db::from_pool(pool);
    db.ping()
        .await
        .expect("ping should succeed against a live database");
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cargo test -p db`
Expected: FAIL — `Db`, `Db::from_pool`, and `Db::ping` do not exist (compile error).

- [ ] **Step 3: Implement the `Db` handle**

Replace `crates/db/src/lib.rs` with:

```rust
//! Database access. All SQL lives in this crate.

use sqlx::postgres::{PgPool, PgPoolOptions};

/// A handle to the organization's PostgreSQL database.
#[derive(Clone)]
pub struct Db {
    pool: PgPool,
}

impl Db {
    /// Connect to the database at `database_url`, opening a connection pool.
    pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
        let pool = PgPoolOptions::new()
            .max_connections(5)
            .connect(database_url)
            .await?;
        Ok(Self { pool })
    }

    /// Build a handle from an existing pool (used in tests).
    pub fn from_pool(pool: PgPool) -> Self {
        Self { pool }
    }

    /// Borrow the underlying pool for repository modules.
    pub fn pool(&self) -> &PgPool {
        &self.pool
    }

    /// Readiness check: run a trivial query to confirm the database answers.
    pub async fn ping(&self) -> Result<(), sqlx::Error> {
        sqlx::query_scalar::<_, i32>("SELECT 1")
            .fetch_one(&self.pool)
            .await?;
        Ok(())
    }
}
```

- [ ] **Step 4: Run the test to verify it passes**

Ensure PostgreSQL is running and `DATABASE_URL` is set (see Prerequisites / Task 7).
Run: `cargo test -p db`
Expected: PASS — 1 test (`ping_succeeds`). `#[sqlx::test]` provisions and drops a temporary database automatically.

- [ ] **Step 5: Commit**

```bash
git add crates/db
git commit -m "feat(db): add Db handle with pool connect and readiness ping"
```

---

## Task 5: `api` — health probes, OpenAPI doc, and router

**Files:**
- Create: `crates/api/src/health.rs`
- Create: `crates/api/src/openapi.rs`
- Modify: `crates/api/src/lib.rs`
- Test: `crates/api/tests/health.rs`

- [ ] **Step 1: Write the failing test**

Create `crates/api/tests/health.rs`:

```rust
use api::{AppState, build_app};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use sqlx::PgPool;
use tower::ServiceExt; // for `oneshot`

fn state(pool: PgPool, app_name: &str) -> AppState {
    AppState {
        db: db::Db::from_pool(pool),
        app_name: app_name.to_string(),
    }
}

#[sqlx::test]
async fn live_returns_ok(pool: PgPool) {
    let app = build_app(state(pool, "Test"));
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/health/live")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(json["status"], "ok");
}

#[sqlx::test]
async fn ready_reports_database_true(pool: PgPool) {
    let app = build_app(state(pool, "Test"));
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/health/ready")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(json["database"], true);
}

#[sqlx::test]
async fn openapi_doc_uses_configured_title(pool: PgPool) {
    let app = build_app(state(pool, "My Museum CMS"));
    let resp = app
        .oneshot(
            Request::builder()
                .uri("/api-docs/openapi.json")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(json["info"]["title"], "My Museum CMS");
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cargo test -p api --test health`
Expected: FAIL — `AppState`, `build_app`, and the routes do not exist (compile error).

- [ ] **Step 3: Implement health handlers**

Create `crates/api/src/health.rs`:

```rust
use axum::{
    Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get,
};
use serde::Serialize;
use utoipa::ToSchema;

use crate::AppState;

/// Liveness payload: the process is running.
#[derive(Serialize, ToSchema)]
pub(crate) struct Live {
    /// Always `"ok"` when the process serves requests.
    pub status: &'static str,
}

/// Readiness payload: dependencies were checked.
#[derive(Serialize, ToSchema)]
pub(crate) struct Ready {
    /// `"ok"` when ready, `"degraded"` otherwise.
    pub status: &'static str,
    /// Whether the database responded to a ping.
    pub database: bool,
}

/// Liveness probe — no dependencies checked.
#[utoipa::path(get, path = "/health/live", responses((status = 200, body = Live)))]
pub(crate) async fn live() -> Json<Live> {
    Json(Live { status: "ok" })
}

/// Readiness probe — confirms the database answers.
#[utoipa::path(
    get,
    path = "/health/ready",
    responses(
        (status = 200, body = Ready, description = "Ready"),
        (status = 503, body = Ready, description = "A dependency is unavailable")
    )
)]
pub(crate) async fn ready(State(state): State<AppState>) -> impl IntoResponse {
    match state.db.ping().await {
        Ok(()) => (
            StatusCode::OK,
            Json(Ready {
                status: "ok",
                database: true,
            }),
        ),
        Err(_) => (
            StatusCode::SERVICE_UNAVAILABLE,
            Json(Ready {
                status: "degraded",
                database: false,
            }),
        ),
    }
}

/// Health routes, parameterized over [`AppState`].
pub(crate) fn routes() -> Router<AppState> {
    Router::new()
        .route("/health/live", get(live))
        .route("/health/ready", get(ready))
}
```

- [ ] **Step 4: Implement the OpenAPI document and route**

Create `crates/api/src/openapi.rs`:

```rust
use axum::{Json, Router, extract::State, routing::get};
use utoipa::OpenApi;

use crate::{AppState, health};

#[derive(OpenApi)]
#[openapi(
    paths(health::live, health::ready),
    components(schemas(health::Live, health::Ready)),
    info(title = "Collection Management System", version = "0.0.0")
)]
struct ApiDoc;

/// Serve the OpenAPI document, overriding the title from runtime config so the
/// product name is never hardcoded.
async fn openapi_json(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> {
    let mut doc = ApiDoc::openapi();
    doc.info.title = state.app_name.clone();
    Json(doc)
}

/// OpenAPI routes, parameterized over [`AppState`].
pub(crate) fn routes() -> Router<AppState> {
    Router::new().route("/api-docs/openapi.json", get(openapi_json))
}
```

- [ ] **Step 5: Implement `AppState` and `build_app`**

Replace `crates/api/src/lib.rs` with:

```rust
//! HTTP API: router, handlers, and OpenAPI document.

mod health;
mod openapi;

use axum::Router;
use db::Db;

/// Shared application state passed to handlers.
#[derive(Clone)]
pub struct AppState {
    /// Database handle for this organization.
    pub db: Db,
    /// User-facing product name (from config). Never hardcoded.
    pub app_name: String,
}

/// Build the application router from shared state.
pub fn build_app(state: AppState) -> Router {
    Router::new()
        .merge(health::routes())
        .merge(openapi::routes())
        .with_state(state)
}
```

- [ ] **Step 6: Run the tests to verify they pass**

Run: `cargo test -p api --test health`
Expected: PASS — 3 tests (`live_returns_ok`, `ready_reports_database_true`, `openapi_doc_uses_configured_title`).

- [ ] **Step 7: Commit**

```bash
git add crates/api
git commit -m "feat(api): add health probes, OpenAPI doc, and router"
```

---

## Task 6: `server` — wire dependencies and serve

**Files:**
- Modify: `crates/server/src/lib.rs`
- Modify: `crates/server/src/main.rs`
- Test: `crates/server/tests/serve.rs`

- [ ] **Step 1: Write the failing test**

Create `crates/server/tests/serve.rs`:

```rust
use std::net::SocketAddr;

use api::AppState;
use db::Db;
use server::serve;
use tokio::net::TcpListener;

#[tokio::test]
async fn serves_health_live_over_tcp() {
    let database_url =
        std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test");
    let db = Db::connect(&database_url).await.expect("connect to database");
    let state = AppState {
        db,
        app_name: "Test".to_string(),
    };

    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr: SocketAddr = listener.local_addr().unwrap();

    let handle = tokio::spawn(async move {
        serve(listener, state).await.unwrap();
    });

    let url = format!("http://{addr}/health/live");
    let body: serde_json::Value = reqwest::get(&url)
        .await
        .expect("request succeeds")
        .json()
        .await
        .expect("json body");
    assert_eq!(body["status"], "ok");

    handle.abort();
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cargo test -p server --test serve`
Expected: FAIL — `server::serve` does not exist (compile error).

- [ ] **Step 3: Implement `run` and `serve`**

Replace `crates/server/src/lib.rs` with:

```rust
//! Server wiring: configuration and startup.

mod config;

pub use config::Config;

use anyhow::Context;
use api::{AppState, build_app};
use db::Db;
use tokio::net::TcpListener;

/// Connect dependencies from `config` and serve until shutdown.
pub async fn run(config: Config) -> anyhow::Result<()> {
    let db = Db::connect(&config.database_url)
        .await
        .context("connecting to the database")?;
    let state = AppState {
        db,
        app_name: config.app_name.clone(),
    };

    let listener = TcpListener::bind(&config.bind_addr)
        .await
        .with_context(|| format!("binding to {}", config.bind_addr))?;
    tracing::info!(addr = %config.bind_addr, "server listening");

    serve(listener, state).await
}

/// Serve the API on an already-bound listener (used by `run` and tests).
pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> {
    let app = build_app(state);
    axum::serve(listener, app)
        .await
        .context("running the HTTP server")?;
    Ok(())
}
```

- [ ] **Step 4: Implement the binary entrypoint**

Replace `crates/server/src/main.rs` with:

```rust
use clap::Parser;
use server::{Config, run};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    let config = Config::parse();
    run(config).await
}
```

- [ ] **Step 5: Run the test to verify it passes**

Ensure PostgreSQL is running and `DATABASE_URL` is set.
Run: `cargo test -p server --test serve`
Expected: PASS — 1 test (`serves_health_live_over_tcp`).

- [ ] **Step 6: Run the whole workspace test suite**

Run: `cargo test --workspace`
Expected: PASS — all tests across `domain`, `db`, `api`, `server`.

- [ ] **Step 7: Commit**

```bash
git add crates/server
git commit -m "feat(server): wire config, database, and HTTP serving"
```

---

## Task 7: Developer ergonomics (local Postgres, task runner, env)

**Files:**
- Create: `docker-compose.yml`
- Create: `.env.example`
- Create: `justfile`
- Modify: `.gitignore`

- [ ] **Step 1: Add a local PostgreSQL service**

Create `docker-compose.yml`:

```yaml
services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: cms_dev
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
```

- [ ] **Step 2: Add an example environment file**

Create `.env.example`:

```dotenv
# Connection string for local development and tests.
# The role must be allowed to CREATE DATABASE (sqlx::test provisions temp DBs).
DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev
BIND_ADDR=0.0.0.0:8080
APP_NAME=Collection Management System
```

- [ ] **Step 3: Add a task runner**

Create `justfile`:

```just
set dotenv-load

# Run the server (reads .env)
run:
    cargo run -p server

# Run the full test suite
test:
    cargo test --workspace

# Format with the nightly toolchain
fmt:
    cargo +nightly fmt

# Lint, treating warnings as errors
lint:
    cargo clippy --workspace --all-targets -- -D warnings

# Format, lint, and test
check: fmt lint test
```

- [ ] **Step 4: Ignore the local env file**

Append to `.gitignore` (a newline then):

```gitignore
.env
```

- [ ] **Step 5: Verify the local stack end to end**

Run:
```bash
docker compose up -d
cp .env.example .env
cargo +nightly fmt
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```
Expected: containers start; formatting clean; clippy reports no warnings; all tests pass.

- [ ] **Step 6: Commit**

```bash
git add docker-compose.yml .env.example justfile .gitignore
git commit -m "chore: add local Postgres, justfile, and env example"
```

---

## Self-Review (completed)

**Spec coverage (foundation slice of `docs/specs/2026-06-02-mvp-architecture.md`):**
- §5 role-named workspace, dependency direction inward → Tasks 1–6 (domain has no I/O deps; api depends on db; server depends on api+db). ✓
- §13 product name never hardcoded; from config → Task 3 (`Config::app_name`) + Task 5 (OpenAPI title override). ✓
- §7 OpenAPI code-first via utoipa → Task 5. ✓
- §8 sqlx + SQL confined to `db` crate → Task 4 (only crate with SQL). ✓
- §12 self-host single binary, sensible defaults → Tasks 3, 6 (defaults for bind/app_name; one binary). ✓
- §19 testing: repositories integration-tested against Postgres; deterministic handler tests → Tasks 4–6. ✓
- §21 first scaffolding task = dissolve `biggus-dickus` package → Task 1. ✓
- Out of scope here (later plans): isolation/credentials (deployment), auth, the data model, search, audit, export. Not foundation gaps.

**Placeholder scan:** no `TODO`/`TBD`/"add error handling"/"similar to" — every step has concrete code and commands. ✓

**Type consistency:** `AppState { db: Db, app_name: String }` is constructed identically in Task 5 tests, Task 6 lib, and Task 6 test; `build_app`/`serve`/`Db::from_pool`/`Db::connect`/`Db::ping` signatures match across tasks; `Config` field names (`database_url`, `bind_addr`, `app_name`) are consistent between Task 3 and Task 6. ✓

---

## Notes for follow-on plans

- Add `storage`, `search`, `auth` crates to `members` when their plans begin.
- CI (gitea actions running `just check`) is intentionally deferred — add once a second plan lands.
- Licensing for the workspace is undecided; `[workspace.package]` deliberately omits `license` until chosen.
