# PLAN-M2a: Server skeleton and `kvitt-domain` On record: landed on `main` as `fdf2093`. As built (SPEC v0.4, D-005 to D-007): Rust 1.99.1 pinned in `rust-toolchain.toml` and the Dockerfile; Dockerfile builds arm64 (shipped) and amd64 via `TARGETARCH`; the OpenAPI equality test's eight normalisations and the `CommonRules` modifier (§14.8); explicit serde rename for `bp_sum_not_10000`; per-crate `deny(clippy::unwrap_used, clippy::expect_used)`; seams as documented in `server/README.md` and §6.3. Where this plan and SPEC differ, SPEC wins. ## 1. Goal Create the Rust Cargo workspace with its three crates, a running `kvitt-server` with config, database pool, schema migration, error type, auth extractor, every route of §7 registered (501 until implemented), `GET /health` and `GET /setup/status`, and a utoipa document equal to `contracts/openapi.yaml`. Implement `kvitt-domain` so that it passes every shared test vector, so M3, M4 and M9 can fill in their own files in parallel. ## 2. Read first 1. SPEC §6.3 Server (stack and crate table) and §6.1 System view. 2. SPEC §7 API outline. 3. SPEC §14.3 OpenAPI 3.1 outline (14.3.1 Common rules, 14.3.2 Error shape and error codes, 14.3.3 Wire objects, 14.3.4 Endpoints, 14.3.5 Examples as contract). 4. SPEC §14.1 SQLite schema v1 and its server-side rules. 5. SPEC §14.5 Domain rules as pure functions (14.5.1 Types, 14.5.2 Functions, 14.5.3 Error tokens, 14.5.5 Test-vector format). 6. SPEC §9.1 Splits, §9.3 Balances, §9.4 Settlement allocation proposal, §9.6 Limits and validation. 7. SPEC §8.2 Device tokens, §8.4 Authorization rules, §8.6 Rate limiting and input limits, §8.7 Privacy and logging, §8.9 Server hardening. 8. SPEC §10.3 Images, registry and build (the `server` image), §10.4 Server configuration and CLI. 9. SPEC §4 FR-20 Health and AC-1.5 (setup status). 10. SPEC §11.2 (row M2a), §11.3 (notes M1, M2a), §14.7 Module layout and ownership map, §14.8 Contract files and the change rule. 11. SPEC §6.3 "Server seams (D-015)", §9.5 Recurring generation (`next_occurrence_date`, clamping), §14.5.2 row "Recurring dates"; `docs/decisions.md` D-004, D-015, D-016. 12. Files: `contracts/openapi.yaml`, `contracts/schema/server-v1.sql`, `contracts/test-vectors/*.json`, `contracts/examples/**`, `CLAUDE.md` (BUILD AND VERIFY, ARCHITECTURE RULES, ANTI-PATTERNS). ## 3. Already established - Rust stable, edition 2024; axum on tokio multi-thread runtime with 2 worker threads; SQLite via sqlx, WAL, migrations embedded with `sqlx::migrate!`; serde; utoipa; tracing JSON lines to stdout; chrono + chrono-tz; uuid v7; image crate; reqwest with rustls; jsonwebtoken (§6.3). M2a needs only the subset it uses; later plans add the rest. - Crates: `kvitt-domain` (library, no I/O, depends only on `serde`, `serde_json` for tests, `uuid`), `kvitt-server` (binary plus `lib.rs`), `kvitt-import` (binary, depends on `kvitt-domain` and `kvitt-server` as a library) (§6.3, CLAUDE.md Server layering). - `migrations/0001_init.sql` is byte-identical to `contracts/schema/server-v1.sql`; a test asserts it (§14.1). - Every connection sets `foreign_keys = ON`, `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5000` (§8.9). Every write transaction is `BEGIN IMMEDIATE`; `server_version` comes from `UPDATE sync_sequence SET value = value + 1 WHERE id = 1 RETURNING value` (§14.1). - Error type covers exactly the 25 codes of §14.3.2 with their HTTP statuses and the JSON shape `{code, message, field}`; `not_implemented` (501) exists only during M2a to M4 (§14.3.2). - Auth: bearer `kvt_` token, SHA-256 hex lookup, 401 `unauthorized` / `device_revoked`, `last_seen_at` and `app_version_code` updated at most once per hour per device; admin-only routes 403 `forbidden` for members (§8.2, §8.4). - Every route of §7 is registered up front with a handler returning 501 `not_implemented`, and the utoipa `ApiDoc` lists them all, so M3 and M4 only fill in handler bodies in their own files (§11.2, §11.3). - `GET /health` returns `{status, server_version, schema_version, db_ok}`, 503 `degraded` when `SELECT 1` fails, unauthenticated, under 100 ms on the Pi (FR-20, §14.3.4 E-1). `GET /setup/status` returns `{"initialized": bool}` (AC-1.5). - Environment variables are exactly those of §10.4 with their defaults; `Europe/Stockholm` is a code constant. Subcommands are exactly `serve`, `bootstrap`, `pair-device`, `rotate-epoch`, `healthcheck`, `migrate` (§10.4); M2a implements `serve`, `migrate`, `healthcheck`. - `server/Dockerfile`: stage 1 `rust:-bookworm` builds both binaries release `--locked` for `aarch64-unknown-linux-gnu` (no `target-cpu=native`); stage 2 `gcr.io/distroless/cc-debian12:nonroot` arm64, binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve` (§10.3). - Every response carries `X-Kvitt-Server-Version`; authenticated endpoints require `X-Kvitt-Protocol: 1` else 426 `protocol_unsupported`; unknown request fields ignored; missing/wrong fields 400 `invalid_request`; path ids that are not UUIDs 404 `not_found`; optional response fields present as `null` (§14.3.1). - Logs never contain tokens, invite codes, names, descriptions, notes, amounts or picture bytes; they log route templates, not raw paths (§8.7). - No `unwrap()`/`expect()` outside `main` start-up, no `unsafe` (CLAUDE.md). - M1 has landed (commit `c6df2ab`): `contracts/openapi.yaml` exists with 33 operations and 60 schemas, and the utoipa document must match it; differences may only be representational. M1's readings are now explicit in SPEC v0.2 (§14.3.1, §14.3.2 `not_implemented` on no operation, §14.3.4 E-1/E-3/E-12/E-17/E-31/E-33, §14.4.3 result key sets, §14.5.1, §14.5.2, §14.5.5); do not re-decide them. - `contracts/openapi.yaml` may be changed by M2a only for representational differences that do not change meaning (key order, `$ref` naming, description wording), listed in the report, with no spec bump; anything semantic is a contract change (§11.3, §14.8, D-016). ## 4. Owned paths From SPEC §14.7 (v0.3): - `server/Cargo.toml`, `server/Cargo.lock`, every crate's `Cargo.toml`, `server/rust-toolchain.toml`, `server/Dockerfile`, `server/crates/kvitt-domain/**` (after landing: contract-change plans only for `kvitt-domain`; M3, M4 and M9 may add dependency lines to a crate's `Cargo.toml`). - `kvitt-server`: `migrations/`, `src/{main,lib,config,error,router,openapi,auth,time}.rs` (including `serve` and `migrate`), `src/db/**`, `src/wire/**`, `src/api/{mod,health,setup}.rs`, `src/cli/{mod,healthcheck}.rs`, and M2a's own tests (any `tests/*.rs` without the prefixes `core_`, `sync_`, `scheduler_`, `push_`). - Shell files in later milestones' paths (D-004), which pass to their owners as soon as M2a lands: the 501 handlers in `src/api/{invites,landing,me,pictures,devices,accounts,categories,recurring,proposal,apk,rate_limit,sync}.rs`, stub subcommands in `src/cli/{bootstrap,pair_device,rotate_epoch}.rs`, `src/tasks/mod.rs` (a `start` function that does nothing yet), `src/audit/` and `src/push/` (the `AuditWriter` and `PushQueue` traits with no-op implementations, D-015), and `server/crates/kvitt-import/` (a crate that builds, with a `main.rs` that prints usage and exits non-zero). - Exception granted by §11.3 and §14.8: representational (non-semantic) edits to `contracts/openapi.yaml` (D-016). Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator. Never edit `SPEC.md` or `docs/decisions.md`: put proposed decision entries in the report (CLAUDE.md › Orchestration). Resolved open points (SPEC v0.3; the start prompt may repeat them): - (a) Stubs: M2a creates the shell files listed above (D-004); M3, M4 and M9 own their bodies. - (b) `server/Cargo.lock` and every `Cargo.toml` are M2a's; later milestones add dependency lines (§14.7). - (c) M2a's own integration tests use any name without the prefixes `core_`, `sync_`, `scheduler_`, `push_`; list the names in the report. - (d) The protocol-header check (426), `X-Kvitt-Server-Version` and the §8.6 body limits are layers in `router.rs`. M3's rate limit is a layer in `src/api/rate_limit.rs` attached at one line in `router.rs` that M2a marks with a comment (attach a pass-through layer from the shell until M3 fills it) (D-015). - (e) `serve` calls `tasks::start` from `src/tasks/mod.rs` once after migrations; M4 owns `src/tasks/**`. `AuditWriter` and `PushQueue` are traits with no-op implementations passed through the application state; M4 replaces the implementations, M3 calls the traits (D-015). - (f) The OpenAPI equality test compares parsed values (YAML parsed and compared as JSON values), not text (D-016). - Also (D-015): implement `next_occurrence_date` and the day clamping of §9.5 in `kvitt-domain` (`src/recurring.rs`, pure, Rust only, unit tests for every AC-9.2 clamping case and the `null` cases); report the exact signatures. - Also (§10.3): `rust-toolchain.toml` pins the exact current stable Rust release, and the Dockerfile's stage 1 uses `rust:-bookworm`. ## 5. Deliverables - `server/Cargo.toml`: workspace with members `crates/kvitt-domain`, `crates/kvitt-server`, `crates/kvitt-import`; shared lints (deny `unsafe_code`, clippy `unwrap_used`/`expect_used` outside tests). - `server/Cargo.lock`: committed. - `server/rust-toolchain.toml`: the exact current stable Rust release (see step 3). - `server/Dockerfile`: the two-stage arm64 image of §10.3. - `server/crates/kvitt-domain/Cargo.toml`, `src/lib.rs`, `src/split.rs`, `src/balance.rs`, `src/allocation.rs`, `src/settlement.rs`, `src/recurring.rs`: types and functions of §14.5.1 and §14.5.2, including the Rust-only recurring dates. - `server/crates/kvitt-domain/tests/vectors.rs`: runs every case of the five Rust vector files in `contracts/test-vectors/`. - `server/crates/kvitt-server/Cargo.toml`, `src/main.rs` (CLI parsing, start-up), `src/lib.rs` (public modules for `kvitt-import`), `src/config.rs` (§10.4 variables), `src/error.rs` (§14.3.2), `src/router.rs` (every route of §7, layers of §14.3.1), `src/openapi.rs` (utoipa `ApiDoc`), `src/auth.rs` (device and admin extractors, §8.2, §8.4), `src/time.rs` (fixed RFC 3339 form, `Europe/Stockholm` constant, injectable clock). - `src/db/mod.rs`, `src/db/pool.rs` (pool with the §8.9 pragmas, `BEGIN IMMEDIATE` helper), `src/db/sequence.rs` (next `server_version`). - `migrations/0001_init.sql`: byte copy of `contracts/schema/server-v1.sql`. - `src/wire/{mod,user,device,invite,account,category,expense,settlement,recurring,audit,sync}.rs`: serde wire objects of §14.3.3 and §14.4 with utoipa schemas, arrays sorted as §14.3.3 says. - `src/api/mod.rs`, `src/api/health.rs` (E-1), `src/api/setup.rs` (E-2). - `src/cli/mod.rs` (subcommand dispatch, `serve`, `migrate`), `src/cli/healthcheck.rs`. - `src/tasks/mod.rs` with `start`; `src/audit/` and `src/push/` with the `AuditWriter` and `PushQueue` traits and no-op implementations (D-015). - 501 stubs for the remaining routes and stub subcommands in the shell files of §4 (D-004). - Integration tests in `kvitt-server/tests/` (names per resolved point c): OpenAPI document equals `contracts/openapi.yaml`; `0001_init.sql` equals `contracts/schema/server-v1.sql` byte for byte; every route returns 501 `not_implemented` with the error shape; E-1 and E-2 responses; auth extractor cases (missing, unknown, revoked token; member on admin route); protocol header 426; error JSON for every code serialises like `contracts/examples/errors/.json`. - `server/crates/kvitt-import/`: a crate that builds and depends on `kvitt-domain` and `kvitt-server`, with a usage-only `main.rs` (D-004). ## 6. Steps 1. Worktree. From the main checkout `/Users/jhag/projects/kvitt`: `git worktree add .claude/worktrees/agent-m2a -b agent-m2a main`, then in `.claude/worktrees/agent-m2a`: `git reset --hard main`. No git-ignored files are needed; copy none. Run `tools/install-git-hooks.sh`. 2. Read §2 in order. Confirm `contracts/` exists on `main` (M1 landed); if not, stop and report. 3. Versions: "Rust stable" (§6.3) and `rust:-bookworm` (§10.3). Look up the current stable Rust release on the official Rust site (rust-lang.org / the Rust release blog) and the matching tag on the official Docker Hub `rust` image page; pin that exact version (for example `1.NN.P`, not `stable`) in `rust-toolchain.toml` and the same version in the Dockerfile's `rust:-bookworm`. Look up current stable versions of each crate you add on crates.io / docs.rs and the digest of `gcr.io/distroless/cc-debian12:nonroot`. Confirm current axum, sqlx and utoipa usage from their official docs (CLAUDE.md "Fast-moving APIs"). Record every version in the report. 4. Write `kvitt-domain` test-first: `tests/vectors.rs` loads `../../../contracts/test-vectors/{compute_split,compute_balances,propose_allocation,validate_settlement,over_settled_accounts}.json` and checks every case; then implement §14.5.2 with the error order of the enums. Run `cargo test -p kvitt-domain`. 5. Write `config.rs`, `time.rs`, `error.rs`, `db/**`, `migrations/0001_init.sql` (copy the bytes with `cp contracts/schema/server-v1.sql server/crates/kvitt-server/migrations/0001_init.sql`), `wire/**`. 6. Write `auth.rs` and `router.rs` with every route of §7, the §14.3.1 headers and §8.6 body limits as layers, the marked rate-limit line, and stub handlers in the shell files (resolved points a, d). 7. Write `openapi.rs` with utoipa annotations on every handler and wire type; make the generated document equal `contracts/openapi.yaml` (parsed values, resolved point f). Only representational changes to the YAML are allowed; list each in the report. 8. Implement E-1, E-2, `serve` (graceful SIGTERM drain up to 10 s, §10.4; calls `tasks::start`), `migrate`, `healthcheck`; write `kvitt-domain`'s recurring dates test-first. 9. Write the integration tests of §5 with an in-memory or temp-file SQLite and an injected clock. 10. Write `server/Dockerfile`. Build it once locally only if no Gradle build is running (CLAUDE.md); record the image size. Do not push it anywhere. 11. Run the gate (§7) in `server/`. Commit on `agent-m2a`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree. ## 7. Gate From SPEC §11.2, verbatim: "Server gate (includes the OpenAPI match test, the migration identity test, the vector tests)". Command, from `server/`: `cargo fmt --check && cargo clippy -- -D warnings && cargo test`. Green means: all three exit 0 in this session; the `test result:` lines show 0 failed and include the OpenAPI match test, the migration identity test and the vector tests (each named in the report). ## 8. HUMAN steps From SPEC §11.2, verbatim: "none". ## 9. Out of scope - Handler bodies of E-3 to E-33 other than 501 stubs (M3: E-3 to E-30, E-33; M4: E-31, E-32). - Invite landing page, pictures, rate-limit logic, `bootstrap`, `pair-device` (M3). - Sync, audit, push, scheduler, `rotate-epoch`, `sync_mutations` pruning (M4). - The Splitwise importer logic (M9). - `infra/` compose files and image publishing (M2c-dev, M2c-pi). - Any Android work (M2b and later). - Semantic changes to any contract file. ## 10. Report - Branch `agent-m2a`, head commit hash after rebasing on `main`. - Gate output: fmt and clippy exit status, every `test result:` line, and the names of the OpenAPI match, migration identity and vector tests. - Versions chosen (Rust, Docker base images with digests, every crate) with the official source. - Representational changes made to `contracts/openapi.yaml`, one line each. - How each resolved point (a) to (f) was applied; the exact `next_occurrence_date` and clamping signatures; the marked rate-limit line. - Proposed decision entries and spec text (numbered from the start prompt's range), never written to `SPEC.md` or `docs/decisions.md` by this agent; or `TODO(spec)` occurrences. - Contract requests, open questions, spec gaps (with section numbers). - Docker image build result (size) if built. ## 11. Numbering Decision ids, spec version and schema version are assigned by the orchestrator in the start prompt; use `TODO(spec)` if none was given.