Files
kvitt/PLAN-M2a.md
T
jacobh2 5e1be38364 Plans and start prompts for every milestone; SPEC v0.2
PLAN-<id>.md for U1, U2, M1, M2a, M2b, M2c-dev, M2c-pi, M3, M4, M5, M6,
M7a-M7f, M8, M9, M10, M11, each with the eleven fixed sections, and a
start prompt per plan in prompts/ plus prompts/README.md (dependency
order and parallel waves).

SPEC v0.2: owner answers D-001 (development-first, M2c split into
M2c-dev and M2c-pi, Gitea registry), D-002 (local backups deleted once
on Drive), D-003 (Splitwise CSV format, English and Swedish variants,
category mapping), OQ-1, OQ-2, OQ-4 answered; M1's readings of
ambiguous contract points stated explicitly in §14.3 to §14.5.
HANDOFF §2 lists the plans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:05:34 +02:00

14 KiB

PLAN-M2a: Server skeleton and kvitt-domain

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. 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), recorded in the report; anything semantic is a contract change (§11.3, §14.8).

4. Owned paths

From SPEC §14.7, verbatim:

  • server/Cargo.toml, server/rust-toolchain.toml, server/Dockerfile, server/crates/kvitt-domain/** (owner M2a; after landing: "contract-change plans only for kvitt-domain; M3, M4 and M9 may add a dependency line to a crate's Cargo.toml (second to land resolves Cargo.lock on rebase)").
  • kvitt-server: migrations/, src/{main,lib,config,error,router,openapi,auth,time}.rs, src/db/**, src/wire/**, src/api/{mod,health,setup}.rs, src/cli/{mod,healthcheck}.rs (owner M2a; after landing: "contract-change plans; M3/M4 may add one line per new module to mod.rs files they need").
  • Exception granted by §11.3: representational (non-semantic) edits to contracts/openapi.yaml.

Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator.

Open points the orchestrator must settle in the start prompt's notes (stop and ask if they are not settled): (a) the 501 stub handlers for E-3 to E-30, E-33 and E-31/E-32 belong in files §14.7 gives to M3 (src/api/{invites,landing,me,pictures,devices,accounts,categories,recurring,proposal,apk}.rs) and M4 (src/api/sync.rs), the subcommand stubs in src/cli/{bootstrap,pair_device,rotate_epoch}.rs, and the third crate in server/crates/kvitt-import/** (M9): whether M2a creates these files as stubs that ownership then passes on; (b) server/Cargo.lock, server/crates/kvitt-server/Cargo.toml and server/crates/kvitt-import/Cargo.toml are not in the ownership table; (c) names for M2a's own integration tests under kvitt-server/tests/ (M3 has core_*, M4 sync_*, scheduler_*, push_*); (d) where the protocol-header check, body-size limits (§8.6) and the rate-limit layer that M3 writes in src/ratelimit.rs attach to router.rs; (e) how M4's scheduler and push sender get started by serve (main.rs/lib.rs) when M4 may only add mod.rs lines; (f) whether the OpenAPI equality test compares parsed values or text.

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 Rust stable version chosen (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: types and functions of §14.5.1 and §14.5.2.
  • 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.
  • 501 stubs for the remaining routes and subcommands, placed as the orchestrator decides (open point a).
  • Integration tests in kvitt-server/tests/ (names per open 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/ minimal crate that builds (if open point a says M2a creates it).

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 the same version in rust-toolchain.toml and the Dockerfile. 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 limits, and stub handlers per open point (a).
  7. Write openapi.rs with utoipa annotations on every handler and wire type; make the generated document equal contracts/openapi.yaml (comparison per open 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), migrate, healthcheck.
  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 open point (a) to (f) was resolved, as given in the start prompt.
  • Numbering used (decision ids, spec version) 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.