Files
biggus-dickus/docs/plans/2026-06-02-foundation.md
T
logaritmisk caa12f5366 docs: add Plan 0 (Foundation) implementation plan
Bite-sized TDD plan: role-named workspace, clap-derive config, sqlx Db handle,
axum health probes + config-driven OpenAPI, server wiring, local dev tooling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:29:25 +02:00

27 KiB
Raw Blame History

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:

[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:

[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:

//! Core domain types and invariants. No I/O dependencies.
  • Step 4: Create the db crate (empty for now)

crates/db/Cargo.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:

//! Database access. All SQL lives in this crate.
  • Step 5: Create the api crate (empty for now)

crates/api/Cargo.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:

//! HTTP API: router, handlers, and OpenAPI document.
  • Step 6: Create the server crate with a placeholder main

crates/server/Cargo.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:

//! Server wiring: configuration and startup.

crates/server/src/main.rs:

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
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:

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:

//! 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):

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
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:

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:

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:

//! 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
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:

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:

//! 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
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:

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:

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:

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:

//! 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
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:

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:

//! 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:

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
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:

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:

# 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:

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):

.env
  • Step 5: Verify the local stack end to end

Run:

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
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 16 (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 46. ✓
  • §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.