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>
27 KiB
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-mcptools and adjust if needed. Add/update dependencies withcargo-mcprather than hand-editing where practical. - Set
DATABASE_URLbefore running DB-backed tests, e.g.export DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev(Task 7 ships adocker-compose.ymland.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-emptysrc/) -
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.tomlwith 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
domaincrate (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
dbcrate (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
apicrate (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
servercrate 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
Dbhandle
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
AppStateandbuild_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
runandserve
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 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
dbcrate → 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-dickuspackage → 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,authcrates tomemberswhen 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 omitslicenseuntil chosen.