Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This guide is a slow, human-friendly walk through the Task example in the Tee system. It is written for junior developers who know basic Rust but may be new to web services, SQL, and layered architecture.

By the end, you should be able to:

  • Explain how the Tasks UI works end to end.
  • Trace a request from the browser to the database and back.
  • Understand why the code is split into interface, app, domain, and data layers.
  • Extend the system in safe, predictable ways.

Tee logo

Who this guide is for

You should be comfortable with:

  • Basic Rust syntax (structs, enums, impl blocks).
  • Reading and running a Cargo project.
  • Basic HTTP concepts (GET, POST, status codes).

You do not need to be an expert in Axum, SQLx, or Postgres. We will explain concepts as they appear.

How to read this guide

We start with what users see (the Task list and detail views), then move inward to the system layers and infrastructure. This mirrors how most people learn: start with a feature, then open the hood.

Sections include:

  • Small code excerpts (never the full file).
  • Diagrams for request flow.
  • Exercises you can try on your own.

What is the Tee system?

The Tee system is a simple, strict architecture:

  • One Rust service.
  • One Postgres database.

Inside the Rust service there are logical layers:

  • Interface: HTTP routing and request handling.
  • App: commands and queries.
  • Domain: rules and types.
  • Data: SQL and database access.

This keeps the system easy to reason about and easy to operate. There is no message broker, no cache tier, and no separate read service.

Repository layout (high level)

src/
  interface/   # HTTP routes, auth, templates, i18n
  app/
    commands/  # state-changing use cases
    queries/   # read-only use cases
  domain/      # domain logic and policies
  data/        # SQL and database access
migrations/    # schema changes
templates/     # Askama HTML templates
static/        # CSS and assets
locales/       # translation files

If this structure is new to you, do not worry. We will visit each folder in depth.

Code example: service startup

This is the real entry point in src/main.rs. It shows the order of startup: load config, initialize logging, connect to the database, run migrations, load translations, build the router, and start the server.

mod app;
mod data;
mod domain;
mod interface;
mod ops;

use anyhow::Context;
use interface::i18n::{Translator, DEFAULT_LOCALE, REQUIRED_LOCALES};

#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    let cfg = ops::config::Config::from_env().context("reading config")?;
    ops::observability::init(&cfg.log_level);

    let pool = data::db::new_pool(&cfg.database_url)
        .await
        .context("connecting to database")?;

    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .context("running migrations")?;

    let translator = Translator::load_from_disk("locales", DEFAULT_LOCALE, REQUIRED_LOCALES)
        .context("loading translation bundles")?;

    let auth_settings = interface::state::AuthSettings::new(
        cfg.session_lifetime,
        cfg.session_idle_timeout,
        cfg.remember_me_lifetime,
    );
    let router = interface::http::build_router(pool, auth_settings, translator);

    let listener = tokio::net::TcpListener::bind(&cfg.bind_addr)
        .await
        .context("binding TCP listener")?;

    tracing::info!("listening on {}", cfg.bind_addr);

    axum::serve(listener, router)
        .await
        .context("serving HTTP")?;
    Ok(())
}

Glossary (quick)

  • Command: a function that changes state (create, update, delete).
  • Query: a function that reads state without changing it.
  • Transaction: a group of database operations that succeed or fail together.
  • Soft delete: marking a row as deleted without removing it.
  • Optimistic concurrency: rejecting updates if the data changed since you read it.

Next: Architecture Overview.

Architecture Overview

The Tee system is built to be boring in the best way: simple to deploy, easy to understand, and predictable under load. This chapter explains the big picture before we dive into the Task screens.

Two physical layers only

The Tee system runs as:

  • One Rust service (the “Tee service”).
  • One Postgres database (the “Tee store”).

No extra services are required. This is a deliberate design choice from docs/Tee-Architecture-Guidelines.md.

Logical layers inside the service

Even though the service is one binary, the code is split into logical layers:

Interface -> App -> Domain -> Data
  • Interface: HTTP, templates, auth, i18n.
  • App: commands and queries.
  • Domain: business rules and types.
  • Data: SQL and database access.

The rules are strict:

  • Interface never talks to the database directly.
  • Domain never uses HTTP or SQL types.
  • Data never implements workflows or policies.

Command and query split

Commands change data. Queries read data. This is sometimes called “logical CQRS” (without extra infrastructure).

Examples:

  • Command: start a task.
  • Query: list tasks.

This split makes it clear where writes happen and where reads happen. It also makes testing easier.

Code examples from the real project

Router wiring and middleware live in src/interface/http.rs:

#![allow(unused)]
fn main() {
pub fn build_router(pool: sqlx::PgPool, auth: AuthSettings, translator: Translator) -> Router {
    Router::new()
        .merge(routes_web::router(pool, auth, translator))
        .nest_service("/static", ServeDir::new("static"))
        .layer(TraceLayer::new_for_http())
        .layer(CompressionLayer::new())
        .layer(TimeoutLayer::with_status_code(
            axum::http::StatusCode::REQUEST_TIMEOUT,
            Duration::from_secs(10),
        ))
        .layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MiB
        .layer(PropagateRequestIdLayer::new(HeaderName::from_static(
            "x-request-id",
        )))
        .layer(SetRequestIdLayer::new(
            HeaderName::from_static("x-request-id"),
            MakeRequestUuid,
        ))
}
}

A command (write) example from src/app/commands/create_task.rs:

#![allow(unused)]
fn main() {
pub async fn handle(
    pool: &PgPool,
    principal: &Principal,
    cmd: CreateTaskCommand,
) -> Result<Uuid, CreateTaskError> {
    shared::ensure_authorized(
        policy::can_create_task(principal),
        CreateTaskError::NotAuthorized,
    )?;

    let title = TaskTitle::parse(&cmd.title_raw)?;
    let description = TaskDescription::parse(&cmd.description_raw)?;
    let priority = TaskPriority::parse(cmd.priority_raw.unwrap_or(3))?;
    let mut tx = pool.begin().await?;
    let id = task_repo::insert_task(
        &mut *tx,
        title.as_str(),
        description.as_str(),
        cmd.due_at,
        priority.as_i16(),
    )
    .await?;
    tx.commit().await?;
    tracing::info!(task_id = %id, "task created");
    Ok(id)
}
}

A query (read) example from src/app/queries/list_tasks.rs:

#![allow(unused)]
fn main() {
let params = task_repo::ListTasksParams {
    status: status_filter,
    created_after: query.created_after,
    created_before: query.created_before,
    search: query.search.as_deref(),
    priority: query.priority,
    sort: query.sort.as_deref(),
    limit: query.limit,
};
let rows = task_repo::list_tasks(pool, params).await?;
}

Why this matters for juniors

When you are new, big codebases can feel confusing. The Tee system reduces surprises by making every layer explicit. You always know:

  • Where to add a new route.
  • Where to add a new use case.
  • Where to add a new SQL query.

End-to-end flow (bird’s eye)

sequenceDiagram
    participant Browser
    participant Interface as Interface Layer
    participant App as App Layer
    participant Domain as Domain Layer
    participant Data as Data Layer
    participant DB as Postgres

    Browser->>Interface: HTTP request
    Interface->>App: call command or query
    App->>Domain: validate rules
    App->>Data: run SQL
    Data->>DB: query/transaction
    DB-->>Data: result
    Data-->>App: rows
    App-->>Interface: result or error
    Interface-->>Browser: HTML or redirect

Exercise

Read src/app/commands/create_task.rs and identify:

  • Where the authorization check happens.
  • Where the transaction starts.
  • Where SQL is called (hint: it is not in this file).

Next: Task List View.

Task List View

The Task list view is the home page of the example application. It introduces most of the system in a safe, read-only way.

What the user sees

  • A list of tasks, each with title, status, priority, due date, and last updated.
  • Filters for status, priority, and created date range.
  • Sorting by updated date, due date, or priority.
  • A primary action to create a new task.

Task list screen image

Task list screen image

Route and handler

The list view is served by:

  • Route: GET /tasks
  • Handler: tasks_list in src/interface/routes_web.rs
  • Template: templates/tasks_list.html

The handler does four things in order:

  1. Check authentication (must be logged in for the UI routes).
  2. Read and validate query parameters (filters and sort).
  3. Call the query layer to fetch tasks.
  4. Render the template with a view model.

How the page is rendered

This project uses server-rendered HTML. That means:

  • The handler builds a Rust struct with all the data the template needs.
  • Askama renders HTML on the server.
  • The browser receives plain HTML.

There is no client-side framework that fetches data after page load. This keeps the behavior predictable and fast to reason about.

User interactions

When a user changes filters or sorting:

  • The browser sends a new GET request with query parameters.
  • The handler re-runs the query with those filters.
  • A new HTML page is returned.

This is simple but powerful. It works even if JavaScript is disabled.

Filtering and validation (explained)

Look at the handler in src/interface/routes_web.rs. It uses helper functions to parse and validate filters:

  • parse_date_filter for created dates.
  • parse_priority for priority range.
  • TaskStatus::parse for status.

Why validate here?

  • The handler is the boundary where raw strings become typed values.
  • The query layer assumes already-validated input.

Query layer: read-only by design

The handler builds a ListTasksQuery:

#![allow(unused)]
fn main() {
let query = queries::list_tasks::ListTasksQuery {
    status,
    created_after,
    created_before,
    search: params.q.clone().filter(|s| !s.trim().is_empty()),
    priority,
    sort,
    limit: 50,
};
let tasks = queries::list_tasks::handle(&st.pool, &principal, query).await?;
}

This query goes to src/app/queries/list_tasks.rs. Queries never write to the database. They only read.

Code example: query to repository

From src/app/queries/list_tasks.rs:

#![allow(unused)]
fn main() {
let status_filter = query.status.map(|status| status.as_str());
let params = task_repo::ListTasksParams {
    status: status_filter,
    created_after: query.created_after,
    created_before: query.created_before,
    search: query.search.as_deref(),
    priority: query.priority,
    sort: query.sort.as_deref(),
    limit: query.limit,
};
let rows = task_repo::list_tasks(pool, params).await?;
}

Code example: SQL filtering

From src/data/task_repo.rs:

#![allow(unused)]
fn main() {
let rows = sqlx::query_as!(
    TaskRow,
    r#"
    SELECT id, title, description, status, created_at, updated_at, due_at, priority, row_version, is_deleted, deleted_at
    FROM tasks
    WHERE is_deleted = FALSE
      AND ($1::text IS NULL OR status = $1)
      AND ($2::timestamptz IS NULL OR created_at >= $2)
      AND ($3::timestamptz IS NULL OR created_at <= $3)
      AND ($4::text IS NULL OR title ILIKE '%' || $4 || '%' OR description ILIKE '%' || $4 || '%')
      AND ($5::smallint IS NULL OR priority = $5)
    ORDER BY
      CASE WHEN $6 = 'due_at' THEN due_at END ASC NULLS LAST,
      CASE WHEN $6 = 'priority' THEN priority END ASC,
      CASE WHEN $6 = 'updated_at' THEN updated_at END DESC,
      updated_at DESC
    LIMIT $7
    "#,
    params.status,
    params.created_after,
    params.created_before,
    params.search,
    params.priority,
    params.sort,
    params.limit
)
.fetch_all(executor)
.await?;

Ok(rows)
}

Repository and SQL

The query calls the repository:

  • src/data/task_repo.rs -> list_tasks

The SQL filters out deleted tasks and applies optional filters. This matches SPEC-02.

Full request flow (Mermaid)

sequenceDiagram
    participant Browser
    participant Router as Axum Router
    participant Query as ListTasks Query
    participant Repo as Task Repo
    participant DB as Postgres

    Browser->>Router: GET /tasks?status=PLANNED
    Router->>Query: list_tasks(filters)
    Query->>Repo: list_tasks(params)
    Repo->>DB: SELECT ... WHERE is_deleted=false
    DB-->>Repo: rows
    Repo-->>Query: task rows
    Query-->>Router: Task list
    Router-->>Browser: HTML list page

Exercise

Try this in code:

  1. Add a filter for “only tasks with a due date”.
  2. Thread it from the query params, to ListTasksQuery, to SQL.
  3. Add a small label to the UI that shows when the filter is active.

Next: Task Detail View.

Task Detail View

The Task detail view shows one task and lets the user act on it. This is where commands (state changes) appear.

What the user sees

  • Title, description, status, and metadata (created, updated, due, priority).
  • Buttons to start or complete a task, based on status.
  • A form to update details.
  • A button to delete the task.

Task detail screen image

Task detail screen image

Route and handler

  • Route: GET /tasks/:id
  • Handler: task_detail in src/interface/routes_web.rs
  • Template: templates/task_detail.html

The handler:

  1. Checks authentication and CSRF availability.
  2. Calls the get-task query.
  3. Computes which actions are allowed.
  4. Renders the template.

Example logic:

#![allow(unused)]
fn main() {
let task = queries::get_task::handle(&st.pool, &principal, id).await?;
let can_start = task.status == TaskStatus::Planned;
let can_complete = task.status == TaskStatus::InProgress;
let can_edit = task.status != TaskStatus::Completed;
}

User interactions

The detail page supports several user actions:

  • Start task
  • Complete task
  • Update details
  • Delete task

Each action is a form submit (POST). The server validates CSRF, runs the command, and redirects back to the appropriate page. This is the same PRG pattern you saw in the create flow.

Query layer

The detail view uses GetTask from src/app/queries/get_task.rs. It reads a single task and returns a domain Task.

Important: deleted tasks are excluded by default using fetch_task_active in src/data/task_repo.rs.

Action buttons are commands

Buttons in the detail view post to command routes:

  • POST /tasks/:id/start -> StartTask
  • POST /tasks/:id/complete -> CompleteTask
  • POST /tasks/:id/update -> UpdateTaskDetails
  • POST /tasks/:id/delete -> DeleteTask

These routes live in src/interface/routes_web.rs and call the command handlers in src/app/commands/*.

Code example: status transition in StartTask

From src/app/commands/start_task.rs:

#![allow(unused)]
fn main() {
let mut tx = pool.begin().await?;
let row = shared::fetch_task_required(&mut *tx, cmd.id, StartTaskError::NotFound).await?;
let current = shared::parse_status(&row.status, StartTaskError::InvalidStatus)?;
if !can_transition_task(row.is_deleted, current, TaskStatus::InProgress) {
    if row.is_deleted {
        return Err(StartTaskError::TaskDeleted);
    }
    tracing::warn!(
        task_id = %cmd.id,
        from = %current,
        to = %TaskStatus::InProgress,
        "invalid task transition attempt"
    );
    return Err(StartTaskError::InvalidTransition);
}
}

Optimistic concurrency (explained)

Each task has a row_version column. When you update or change status, the command includes the expected row version. The SQL update is guarded by:

WHERE id = $id AND row_version = $expected_row_version

If another user updated the task first, the update affects 0 rows and the command returns a conflict. This is optimistic concurrency.

Why it matters:

  • It prevents accidental overwrites.
  • It avoids heavy locking.

Full flow: start task (Mermaid)

sequenceDiagram
    participant Browser
    participant Router as Axum Router
    participant Command as StartTask Command
    participant Repo as Task Repo
    participant DB as Postgres

    Browser->>Router: POST /tasks/:id/start
    Router->>Command: start_task(id, row_version)
    Command->>Repo: update_task_status(tx)
    Repo->>DB: UPDATE ... WHERE row_version=expected
    DB-->>Repo: updated / 0 rows
    Repo-->>Command: result
    Command-->>Router: redirect or conflict

Exercise

  • Add a read-only badge to show the task ID in the detail template.
  • Add a warning message if the task is completed (read-only mode).

Next: Task Create and Update.

Task Create and Update

This chapter covers how tasks are created and edited. These flows show how validation, commands, and redirects work together.

Create view

User experience

  • A form with title, description, due date, and priority.
  • Submit creates a task and redirects back to the list.

Where it lives

  • Route: GET /tasks/new -> task_new
  • Route: POST /tasks -> tasks_create
  • Template: templates/task_new.html
  • Command: CreateTask in src/app/commands/create_task.rs

Image

  • PLACEHOLDER: Task create screen image.

Task create screen image.

Task create screen image.

How the form works

The create form is a normal HTML form. When the user clicks the submit button:

  1. The browser sends a POST request to /tasks.
  2. The interface layer validates the input.
  3. The create command runs inside a transaction.
  4. The server responds with a redirect to /tasks.

This flow works without JavaScript and is easy to debug.

Code example: handler builds a command

From src/interface/routes_web.rs:

#![allow(unused)]
fn main() {
let cmd = commands::create_task::CreateTaskCommand {
    title_raw: form.title,
    description_raw: form.description,
    due_at: parse_due_date(form.due_at.as_deref(), &invalid_due_msg)?,
    priority_raw: parse_priority(form.priority.as_deref(), &invalid_priority_msg)?,
};

commands::create_task::handle(&st.pool, &principal, cmd).await?;
Ok(Redirect::to("/tasks").into_response())
}

Validation in the create flow

The handler converts raw strings into domain types:

  • TaskTitle::parse
  • TaskDescription::parse
  • TaskPriority::parse

This keeps rules in one place and avoids validation drift.

Example from CreateTask:

#![allow(unused)]
fn main() {
let title = TaskTitle::parse(&cmd.title_raw)?;
let description = TaskDescription::parse(&cmd.description_raw)?;
let priority = TaskPriority::parse(cmd.priority_raw.unwrap_or(3))?;
}

Update flow (detail page)

Updates happen in the detail view:

  • Route: POST /tasks/:id/update
  • Command: UpdateTaskDetails in src/app/commands/update_task_details.rs

Important rules:

  • Completed tasks are read-only.
  • Deleted tasks cannot be edited.
  • Updates use optimistic concurrency.

Code example: update with row_version check

From src/app/commands/update_task_details.rs:

#![allow(unused)]
fn main() {
let updated = task_repo::update_task_details(
    &mut *tx,
    cmd.id,
    &title,
    &description,
    cmd.due_at,
    priority.as_i16(),
    cmd.expected_row_version,
)
.await?;
if updated == 0 {
    return match shared::classify_update_conflict(&mut *tx, cmd.id).await? {
        UpdateConflict::NotFound => Err(UpdateTaskDetailsError::NotFound),
        UpdateConflict::Deleted => Err(UpdateTaskDetailsError::TaskDeleted),
        UpdateConflict::Conflict => Err(UpdateTaskDetailsError::ConcurrencyConflict),
    };
}
}

PRG pattern (Post-Redirect-Get)

After a successful POST, the handler redirects instead of rendering HTML. This prevents double submissions if the user refreshes the page.

Example:

#![allow(unused)]
fn main() {
commands::create_task::handle(&st.pool, &principal, cmd).await?;
Ok(Redirect::to("/tasks").into_response())
}

Delete flow

Delete is a command that marks the task as deleted (soft delete). It is idempotent: deleting an already deleted task is treated as success.

  • Route: POST /tasks/:id/delete
  • Command: DeleteTask in src/app/commands/delete_task.rs

Exercise

  • Add a “Save and view” option that redirects to /tasks/:id after create.
  • Add a small confirmation dialog before delete (client-side only).

Next: Interface Layer.

Interface Layer

The interface layer is the “front door” of the system. It receives HTTP requests, checks authentication and CSRF, parses user input, and renders responses.

In the Tee system, the interface layer must not talk directly to the database. It calls commands and queries instead.

Key files

  • src/interface/http.rs - builds the main router and middleware.
  • src/interface/routes_web.rs - HTML routes for the Tasks UI.
  • src/interface/auth.rs - session auth and CSRF helpers.
  • src/interface/i18n.rs - locale resolution and translation.
  • src/interface/error.rs - error mapping for HTTP responses.
  • src/interface/state.rs - shared state (DB pool, auth settings, translator).

Router and middleware

src/interface/http.rs sets the baseline middleware:

  • Request ID generation and propagation.
  • Compression.
  • Per-request timeout (10 seconds).
  • Body size limit (1 MiB).

These are the minimum protections from docs/Tee-Architecture-Guidelines.md.

Code example: router wiring

From src/interface/routes_web.rs:

#![allow(unused)]
fn main() {
Router::new()
    .route("/", get(|| async { Redirect::to("/tasks") }))
    .route("/auth/login", get(auth_login_form).post(auth_login_submit))
    .route("/auth/logout", axum::routing::post(auth_logout))
    .route("/auth/me", get(auth_me))
    .route("/i18n/locale", post(set_locale))
    .route("/tasks", get(tasks_list).post(tasks_create))
    .route("/tasks/new", get(task_new))
    .route("/tasks/:id", get(task_detail))
    .route("/tasks/:id/start", axum::routing::post(task_start))
    .route("/tasks/:id/complete", axum::routing::post(task_complete))
    .route(
        "/tasks/:id/update",
        axum::routing::post(task_update_details),
    )
    .route("/tasks/:id/delete", axum::routing::post(task_delete))
    .with_state(state)
}

Code example: middleware stack

From src/interface/http.rs:

#![allow(unused)]
fn main() {
Router::new()
    .merge(routes_web::router(pool, auth, translator))
    .nest_service("/static", ServeDir::new("static"))
    .layer(TraceLayer::new_for_http())
    .layer(CompressionLayer::new())
    .layer(TimeoutLayer::with_status_code(
        axum::http::StatusCode::REQUEST_TIMEOUT,
        Duration::from_secs(10),
    ))
    .layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MiB
}

Route handlers

The web routes live in src/interface/routes_web.rs. Each handler does boundary work only:

  • Parse inputs.
  • Check auth and CSRF.
  • Call commands/queries.
  • Render a template or redirect.

Example helper pattern:

#![allow(unused)]
fn main() {
let principal = match require_principal(&auth) {
    Ok(principal) => principal,
    Err(redirect) => return Ok(redirect.into_response()),
};
}

This keeps repetitive auth checks small and consistent.

Async handlers and extractors (explained)

Routes are async fn because they can await I/O:

  • Database calls.
  • Template rendering.
  • Auth lookups.

Axum extractors (like State, Path, Query, and Form) pull data from the request and turn it into typed values. This keeps handlers clean and avoids manual parsing.

Errors at the boundary

Handlers return Result<..., AppError>. The AppError type is mapped to status codes and basic text responses in src/interface/error.rs.

This is also where app-level errors are translated into HTTP responses.

Why this layer matters

This layer protects the rest of the system from bad input. It is also the best place to provide user-friendly errors and redirects.

Exercise

  • Add a new route /tasks/:id/raw that returns the task as JSON. (Hint: you will still call the query layer, but return Json.)

Next: Application Layer: Commands and Queries.

Application Layer: Commands and Queries

The application layer is where use cases live. It tells the system what to do and in what order. It does not know about HTTP, templates, or browser forms.

Two folders

  • src/app/commands/ contains state-changing operations.
  • src/app/queries/ contains read-only operations.

This is the command/query split described in the Tee guidelines.

Commands

A command:

  • Runs in a transaction.
  • Checks authorization through domain policy.
  • Validates rules (like status transitions).
  • Writes to the database.

Example: StartTask in src/app/commands/start_task.rs:

#![allow(unused)]
fn main() {
let mut tx = pool.begin().await?;
let row = shared::fetch_task_required(&mut *tx, cmd.id, StartTaskError::NotFound).await?;
let current = shared::parse_status(&row.status, StartTaskError::InvalidStatus)?;
if !can_transition_task(row.is_deleted, current, TaskStatus::InProgress) {
    return Err(StartTaskError::InvalidTransition);
}
}

Why transactions

A transaction ensures all steps succeed or all fail. Without it, you could read a task, update it, and fail midway, leaving data in a confusing state.

Queries

A query:

  • Is read-only.
  • Does not start a transaction unless needed for consistency.
  • Does not perform side effects.

Example: ListTasks in src/app/queries/list_tasks.rs.

Error mapping

Commands and queries return custom errors. To avoid duplicated mapping logic, errors implement the AppErrorSource trait in src/app/error.rs.

This lets the interface layer translate errors into HTTP responses consistently.

Why enums for errors?

  • They make each failure case explicit.
  • They are easy to match on.
  • They encourage deterministic responses (no hidden surprises).

Code example: error interface

From src/app/error.rs:

#![allow(unused)]
fn main() {
pub enum AppErrorKind {
    Forbidden,
    NotFound,
    Conflict,
    BadRequest,
    Internal,
    Db,
}

pub trait AppErrorSource {
    fn error_kind(&self) -> AppErrorKind;
    fn user_message(&self) -> String;
    fn into_db_error(self) -> Option<sqlx::Error>
    where
        Self: Sized,
    {
        None
    }
}
}

Shared helpers

src/app/commands/shared.rs holds small helper functions to reduce repetition:

  • Authorization checks.
  • Fetching tasks with a consistent “not found” error.
  • Conflict classification when row_version mismatches.

These helpers keep command handlers short and readable.

Code example: conflict classification helper

From src/app/commands/shared.rs:

#![allow(unused)]
fn main() {
pub async fn classify_update_conflict<'a, E>(
    executor: E,
    id: Uuid,
) -> Result<UpdateConflict, sqlx::Error>
where
    E: Executor<'a, Database = Postgres>,
{
    let current_row = task_repo::fetch_task(executor, id).await?;
    Ok(match current_row {
        None => UpdateConflict::NotFound,
        Some(row) if row.is_deleted => UpdateConflict::Deleted,
        Some(_) => UpdateConflict::Conflict,
    })
}
}

Exercise

  • Pick one command (create, start, or delete) and add a log line when it succeeds. Note where logs belong and where they do not.

Next: Domain Layer.

Domain Layer

The domain layer defines the rules of the task system. It does not know about HTTP or SQL. It only knows business concepts.

Key files

  • src/domain/task.rs - Task, TaskStatus, transition rules.
  • src/domain/types.rs - validation for title, description, priority.
  • src/domain/policy.rs - authorization hooks.

Task status and transitions

TaskStatus is an enum with three values:

  • PLANNED
  • IN_PROGRESS
  • COMPLETED

Transitions are controlled by a pure function:

PLANNED -> IN_PROGRESS (allowed)
IN_PROGRESS -> COMPLETED (allowed)
Other transitions (not allowed)

This logic is implemented in src/domain/task.rs and used by commands.

Code example: status enum and transitions

From src/domain/task.rs:

#![allow(unused)]
fn main() {
pub enum TaskStatus {
    Planned,
    InProgress,
    Completed,
}

pub fn can_transition(from: TaskStatus, to: TaskStatus) -> bool {
    matches!(
        (from, to),
        (TaskStatus::Planned, TaskStatus::InProgress)
            | (TaskStatus::InProgress, TaskStatus::Completed)
    )
}
}

Value types and validation

src/domain/types.rs defines types like TaskTitle and TaskPriority. Each type has a parse function that checks rules and returns an error if the input is invalid.

Why this matters:

  • Validation is consistent everywhere.
  • Rules are easy to test without a database.

Code example: title validation

From src/domain/types.rs:

#![allow(unused)]
fn main() {
pub fn parse(raw: &str) -> Result<Self, TaskTitleError> {
    let s = raw.trim();
    if s.is_empty() {
        return Err(TaskTitleError::Empty);
    }
    if s.chars().count() > 200 {
        return Err(TaskTitleError::TooLong);
    }
    Ok(Self(s.to_string()))
}
}

Policy hooks

src/domain/policy.rs defines functions like can_create_task. In the reference implementation they are permissive, but they provide an explicit seam for real authorization.

Code example: policy seam

From src/domain/policy.rs:

#![allow(unused)]
fn main() {
pub fn can_delete_task(_principal: &Principal) -> bool {
    true
}

pub fn can_view_tasks(_principal: &Principal) -> bool {
    true
}
}

Exercise

  • Add a new validation rule for title length and update the error message.
  • Add a new policy rule that only allows deletes for admins (fake a role check for now).

Next: Data Layer and SQL.

Data Layer and SQL

The data layer is the only place where SQL appears. This keeps database logic explicit and easy to review.

Key files

  • src/data/task_repo.rs - SQL for tasks.
  • src/data/auth_repo.rs - SQL for users and sessions.
  • src/data/db.rs - connection pool setup.
  • migrations/ - schema changes.

Task repository

src/data/task_repo.rs defines functions like:

  • insert_task
  • fetch_task
  • list_tasks
  • update_task_status
  • update_task_details
  • soft_delete_task

Each function uses parameterized SQL. This avoids SQL injection and keeps query shapes explicit.

Code example: row mapping

From src/data/task_repo.rs:

#![allow(unused)]
fn main() {
pub struct TaskRow {
    pub id: Uuid,
    pub title: String,
    pub description: String,
    pub status: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub due_at: Option<DateTime<Utc>>,
    pub priority: i16,
    pub row_version: i64,
    pub is_deleted: bool,
    pub deleted_at: Option<DateTime<Utc>>,
}
}

Soft delete

Instead of deleting rows, tasks are marked with:

  • is_deleted = true
  • deleted_at = now()

Queries filter out deleted tasks by default. This matches SPEC-02.

Optimistic concurrency

Updates check row_version:

  • The command provides an expected version.
  • The SQL update only succeeds if it matches.
  • Otherwise the command returns a conflict.

This avoids heavy database locking while still preventing lost updates.

Code example: concurrency guard in SQL

From src/data/task_repo.rs:

#![allow(unused)]
fn main() {
let result = sqlx::query!(
    r#"
    UPDATE tasks
    SET status = $2,
        updated_at = now(),
        row_version = row_version + 1
    WHERE id = $1
      AND row_version = $3
      AND is_deleted = FALSE
    "#,
    id,
    status.as_str(),
    expected_row_version
)
.execute(executor)
.await?;
}

Migrations

Migrations live in migrations/ and are required for schema changes. Never change the database manually in production.

Example migrations:

  • 0001_create_tasks.sql
  • 0002_task_lifecycle.sql
  • 0003_task_extensions.sql

Code example: constraints from a migration

From migrations/0003_task_extensions.sql:

DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1
        FROM pg_constraint
        WHERE conname = 'tasks_deleted_consistency'
    ) THEN
        ALTER TABLE tasks
            ADD CONSTRAINT tasks_deleted_consistency
            CHECK (
                (is_deleted = FALSE AND deleted_at IS NULL)
                OR
                (is_deleted = TRUE AND deleted_at IS NOT NULL)
            );
    END IF;
END $$;

DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1
        FROM pg_constraint
        WHERE conname = 'tasks_priority_range'
    ) THEN
        ALTER TABLE tasks
            ADD CONSTRAINT tasks_priority_range
            CHECK (priority BETWEEN 1 AND 5);
    END IF;
END $$;

SQLx compile-time checking

This project uses SQLx with prepare metadata. The idea is:

  • SQL is checked against the database schema.
  • Types are verified at compile time.

The workflow is described in docs/Development-Guide.md.

You will see SQLx macros like query! and query_as!. These macros:

  • Capture the SQL in code.
  • Check column names and types.
  • Generate Rust structs that match the result.

Exercise

  • Add a new column archived_at and write a migration for it.
  • Add a new repo function that lists only archived tasks.

Next: Authentication and Sessions.

Authentication and Sessions

The Tasks UI is protected by login. Authentication is handled in the interface layer, while authorization rules are enforced in commands and queries.

Where to look

  • docs/Authentication.md - high level explanation.
  • docs/SPECS/SPEC-03-AUTH.md - normative rules.
  • src/interface/auth.rs - implementation.
  • src/data/auth_repo.rs - SQL for users and sessions.

The login flow

  1. User visits /auth/login (GET) and sees a login form.
  2. User submits credentials (POST).
  3. Server verifies the password.
  4. A new session is created and stored in the database.
  5. The session token is set as a secure cookie.

This is a classic server-side session setup.

Code example: session creation on login

From src/interface/routes_web.rs:

#![allow(unused)]
fn main() {
let user = user.expect("validated above");
let session_token = auth::generate_session_token();
let session_hash = auth::hash_token(&session_token);
let lifetime = if remember_me {
    st.auth.remember_me_lifetime
} else {
    st.auth.session_lifetime
};
let expires_at = chrono::Duration::from_std(lifetime)
    .map_err(|_| AppError::Internal("invalid session lifetime".to_string()))?;
let expires_at = Utc::now() + expires_at;
let session_id =
    auth_repo::insert_session(&st.pool, user.id, &session_hash, expires_at).await?;
tracing::info!(user_id = %user.id, session_id = %session_id, "login success");

let mut response = Redirect::to("/tasks").into_response();
let session_cookie = auth::session_cookie(&session_token, &st.auth, remember_me);
let login_cookie = auth::clear_login_csrf_cookie();
response
    .headers_mut()
    .append(SET_COOKIE, session_cookie.to_string().parse().unwrap());
response
    .headers_mut()
    .append(SET_COOKIE, login_cookie.to_string().parse().unwrap());
}

Session security (important)

Session cookies follow strict rules:

  • __Host- prefix.
  • Secure, HttpOnly, SameSite=Lax, Path=/.
  • No Domain attribute.

Tokens are random, and only a hash is stored in the database.

Why hash the token?

  • If the database is leaked, attackers cannot reuse session tokens directly.
  • The raw token only exists in the user’s browser cookie.

From src/interface/auth.rs:

#![allow(unused)]
fn main() {
pub fn session_cookie(token: &str, settings: &AuthSettings, remember_me: bool) -> Cookie<'static> {
    let max_age = if remember_me {
        settings.remember_me_lifetime
    } else {
        settings.session_lifetime
    };
    let max_age = cookie::time::Duration::seconds(max_age.as_secs() as i64);
    Cookie::build((SESSION_COOKIE_NAME, token.to_string()))
        .path("/")
        .secure(true)
        .http_only(true)
        .same_site(SameSite::Lax)
        .max_age(max_age)
        .build()
}
}

CSRF protection

All state-changing routes require CSRF validation. The interface layer checks the token before calling commands.

This is required because the UI uses HTML forms (not a SPA).

Principal and policy

Authenticated requests resolve to a Principal object. That principal is passed into commands and queries, where policy functions decide what is allowed.

This keeps authentication and authorization separate, which is simpler to reason about and easier to test.

Exercise

  • Follow the login route in src/interface/routes_web.rs and write down where the CSRF token is created and validated.
  • Add a log line for successful login and logout.

Next: Internationalization.

Internationalization

The Tee system supports multiple languages. This is implemented in the interface layer and uses Fluent files stored in the repository.

Key files

  • docs/SPECS/SPEC-04-I18N.md
  • src/interface/i18n.rs
  • locales/<locale>/main.ftl

How locale is chosen

The locale is resolved in this order:

  1. User preference (if available).
  2. locale cookie.
  3. Accept-Language header.
  4. Default locale (usually en).

This logic is implemented in src/interface/i18n.rs.

Translator and templates

The Translator loads Fluent bundles at startup and provides a text method. Handlers call the translator and pass localized strings into templates. Templates do not perform translation lookups.

This keeps templates simple and avoids I/O during rendering.

If a key is missing, the translator falls back to the default locale and can return the key name as a last resort. This avoids crashing a page because of a missing translation.

Code example: loading bundles

From src/interface/i18n.rs:

#![allow(unused)]
fn main() {
pub fn load_from_disk<P: AsRef<Path>>(
    root: P,
    default_locale: &str,
    required_locales: &[&str],
) -> Result<Self, anyhow::Error> {
    let root = root.as_ref();
    let mut bundles = HashMap::new();

    for entry in fs::read_dir(root).context("reading locales directory")? {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        let locale_code = entry.file_name().to_string_lossy().to_string();
        let Some(langid) = parse_locale(&locale_code) else {
            warn!(code = %locale_code, "skipping invalid locale directory name");
            continue;
        };
        let path = entry.path().join("main.ftl");
        if !path.exists() {
            continue;
        }
        let bundle = load_bundle(&langid, &path)?;
        bundles.insert(langid, bundle);
    }
}

Code example: passing localized strings to templates

From src/interface/routes_web.rs:

#![allow(unused)]
fn main() {
fn build_layout_texts(translator: &Translator, locale: &Locale) -> LayoutTexts {
    LayoutTexts {
        brand: translator.text(&locale.0, "layout-brand", None),
        nav_tasks: translator.text(&locale.0, "layout-nav-tasks", None),
        theme_label: translator.text(&locale.0, "layout-theme-label", None),
        theme_light: translator.text(&locale.0, "layout-theme-light", None),
        theme_dark: translator.text(&locale.0, "layout-theme-dark", None),
        theme_system: translator.text(&locale.0, "layout-theme-system", None),
        locale_label: translator.text(&locale.0, "layout-locale-label", None),
    }
}
}

Date and time formatting

The interface layer formats dates with helper functions like format_date and format_datetime. These functions apply locale-aware formatting before values reach the template.

Adding a new locale

Steps:

  1. Create a new folder under locales/<lang>.
  2. Add a main.ftl file with the required keys.
  3. Restart the service to reload bundles.

Exercise

  • Add a new translation key to all locale files.
  • Remove the key from one locale and observe the fallback behavior.

Next: Theming and UI Assets.

Theming and UI Assets

The Tasks UI is server-rendered HTML with a small amount of CSS and optional JavaScript. Theming is handled through a class toggle in the layout.

Key files

  • templates/layout.html - base layout shared by pages.
  • static/app.css - styles.
  • templates/tasks_list.html, templates/task_detail.html, templates/task_new.html.

Light and dark themes

The layout template sets a theme class on the root element. CSS rules then apply the correct colors.

The UI does not rely on a SPA framework. This is intentional:

  • Pages work without JavaScript.
  • The system is easier to reason about.

Code example: theme handling script

From templates/layout.html:

<script>
  // Theme handling: light, dark, or system preference.
  (function() {
    const key = "theme";
    const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
    const baseHtmlLight = "min-h-full bg-slate-50 text-slate-900";
    const baseHtmlDark = "min-h-full bg-slate-950 text-slate-100";
    const baseBodyLight = "min-h-full bg-slate-50 text-slate-900 transition-colors duration-200";
    const baseBodyDark = "min-h-full bg-slate-950 text-slate-100 transition-colors duration-200";

    function readPref() {
      try { return localStorage.getItem(key) || "system"; }
      catch (_) { return "system"; }
    }

    function writePref(mode) {
      try { localStorage.setItem(key, mode); } catch (_) { /* ignore */ }
    }

    function applyTheme(mode) {
      const useDark = mode === "dark" || (mode === "system" && prefersDark.matches);

      // Reset base classes depending on target theme to avoid mixed states.
      document.documentElement.className = useDark ? baseHtmlDark : baseHtmlLight;
      if (document.body) {
        document.body.className = useDark ? baseBodyDark : baseBodyLight;
      }

      if (useDark) {
        document.documentElement.classList.add("dark");
        if (document.body) document.body.classList.add("dark");
        document.documentElement.style.colorScheme = "dark";
      } else {
        document.documentElement.classList.remove("dark");
        if (document.body) document.body.classList.remove("dark");
        document.documentElement.style.colorScheme = "light";
      }

      document.documentElement.dataset.theme = mode;
    }

    const initial = readPref();
    applyTheme(initial);

    prefersDark.addEventListener("change", () => {
      if (readPref() === "system") {
        applyTheme("system");
      }
    });

    window.__setTheme = (mode) => {
      writePref(mode);
      applyTheme(mode);
    };

    window.__getTheme = () => readPref();
  })();
</script>

Assets

Static assets are served from /static by the interface layer. This keeps the UI assets bundled with the service.

Code example: CSS file location

From static/app.css:

body { font-family: system-ui, sans-serif; margin: 2rem; }
nav a { margin-right: 1rem; }
input, button { font-size: 1rem; }
label { display: inline-block; margin-right: 1rem; }

Exercise

  • Add a new CSS class for a “warning” badge and use it in a template.
  • Add a cookie to remember the user’s theme choice.

Next: Observability and Errors.

Observability and Errors

Observability is how you understand what the system is doing in production. The Tee system keeps this simple and explicit.

Logging and tracing

  • src/ops/observability.rs sets up structured logging.
  • Requests carry a request ID through the middleware stack.
  • Commands log important lifecycle events (create, start, complete, delete).

This makes debugging much easier.

Code example: logging initialization

From src/ops/observability.rs:

#![allow(unused)]
fn main() {
pub fn init(log_level: &str) {
    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(log_level));
    fmt().with_env_filter(filter).init();
}
}

Error mapping

The interface layer converts errors into HTTP responses:

  • 403 for forbidden.
  • 404 for not found.
  • 409 for conflicts.
  • 400 for bad requests.
  • 500 for unexpected failures.

This mapping lives in src/interface/error.rs and uses the AppErrorSource trait from src/app/error.rs.

Code example: error to response mapping

From src/interface/error.rs:

#![allow(unused)]
fn main() {
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        match self {
            AppError::AuthenticationRequired => {
                (StatusCode::UNAUTHORIZED, "authentication required").into_response()
            }
            AppError::CsrfViolation => (StatusCode::FORBIDDEN, "csrf violation").into_response(),
            AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden").into_response(),
            AppError::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
            AppError::Conflict(msg) => (StatusCode::CONFLICT, msg).into_response(),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
            AppError::Template(_) => {
                (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response()
            }
            AppError::Db(_) => {
                (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response()
            }
            AppError::Command(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
            AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(),
        }
    }
}
}

Why explicit errors matter

Users get consistent responses, and developers can quickly identify where an error came from. It also makes testing easier.

Exercise

  • Add a log line for invalid task transitions.
  • Add a counter metric for command failures (even if it is a placeholder).

Next: Development Workflow.

Development Workflow

This chapter summarizes the day-to-day workflow, based on docs/Development-Guide.md.

Prerequisites

You will need:

  • Rust toolchain (via rustup).
  • PostgreSQL (local or containerized).
  • sqlx-cli for SQL checks and migrations.

Environment variables

Required:

  • DATABASE_URL

Optional:

  • BIND_ADDR
  • LOG_LEVEL

Typical workflow

  1. Start Postgres.
  2. Set DATABASE_URL.
  3. Run SQLx prepare:
    cargo sqlx prepare --database-url "$DATABASE_URL"
    
  4. Run the service:
    cargo run
    

Migrations

Migrations live in migrations/. Use:

cargo sqlx migrate run

Never change schema manually in production.

Pre-PR check

Use the existing script:

bash scripts/verify.sh

This verifies formatting, builds, SQLx metadata, and tests.

Code example: verify script

From scripts/verify.sh:

export SQLX_OFFLINE=true

echo "==> rustfmt"
cargo fmt --all -- --check

echo "==> build"
cargo build --locked

echo "==> clippy"
cargo clippy --all-targets --all-features --locked -- -D warnings

echo "==> test"
cargo test --all --locked

Exercise

  • Add a new migration and run cargo sqlx prepare.
  • Run scripts/verify.sh and fix any failures.

Next: Extending the System.

Extending the System

This chapter shows safe ways to extend the Task example while staying inside Tee system constraints.

Add a new field to tasks

Steps:

  1. Add a migration in migrations/.
  2. Update SQL in src/data/task_repo.rs.
  3. Update domain types if needed.
  4. Update commands and queries.
  5. Update templates and routes.

Always update SQLx metadata after SQL changes.

Add a new command

Example: “Archive Task”.

  • Create a new command file under src/app/commands/.
  • Add a route in src/interface/routes_web.rs.
  • Add a button in the template.
  • Add SQL in src/data/task_repo.rs.

Code example: register a command module

From src/app/commands/mod.rs:

#![allow(unused)]
fn main() {
pub mod complete_task;
pub mod create_task;
pub mod delete_task;
pub(crate) mod shared;
pub mod start_task;
pub mod update_task_details;
}

Add a REST API

The Tee system allows a separate API router. Start with:

  • docs/API-Implementation-Guide.md
  • docs/Add-Rest-API.md

Key rule: keep API routes separate from HTML routes.

Add a background job

If you need background work, use the same binary and database. Do not introduce a new always-on service unless you have approval.

Code example: add a route

From src/interface/routes_web.rs:

#![allow(unused)]
fn main() {
Router::new()
    .route("/tasks", get(tasks_list).post(tasks_create))
    .route("/tasks/new", get(task_new))
    .route("/tasks/:id", get(task_detail))
    .route("/tasks/:id/start", axum::routing::post(task_start))
    .route("/tasks/:id/complete", axum::routing::post(task_complete))
    .route(
        "/tasks/:id/update",
        axum::routing::post(task_update_details),
    )
    .route("/tasks/:id/delete", axum::routing::post(task_delete))
    .with_state(state)
}

Exercise

  • Draft a plan for adding “labels” to tasks.
  • Identify which layer each change belongs to.

You have reached the end of the guide.