Files

16 KiB
Raw Permalink Blame History

PLAN-M2a: Server skeleton and kvitt-domain

On record: landed on main as 06f48ef. 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:<stable>-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:<that version>-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/<code>.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:<stable>-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:<version>-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.