Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
16 KiB
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
- SPEC §6.3 Server (stack and crate table) and §6.1 System view.
- SPEC §7 API outline.
- 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).
- SPEC §14.1 SQLite schema v1 and its server-side rules.
- 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).
- SPEC §9.1 Splits, §9.3 Balances, §9.4 Settlement allocation proposal, §9.6 Limits and validation.
- 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.
- SPEC §10.3 Images, registry and build (the
serverimage), §10.4 Server configuration and CLI. - SPEC §4 FR-20 Health and AC-1.5 (setup status).
- 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.
- SPEC §6.3 "Server seams (D-015)", §9.5 Recurring generation (
next_occurrence_date, clamping), §14.5.2 row "Recurring dates";docs/decisions.mdD-004, D-015, D-016. - 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 onserde,serde_jsonfor tests,uuid),kvitt-server(binary pluslib.rs),kvitt-import(binary, depends onkvitt-domainandkvitt-serveras a library) (§6.3, CLAUDE.md Server layering). migrations/0001_init.sqlis byte-identical tocontracts/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 isBEGIN IMMEDIATE;server_versioncomes fromUPDATE 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, 401unauthorized/device_revoked,last_seen_atandapp_version_codeupdated at most once per hour per device; admin-only routes 403forbiddenfor members (§8.2, §8.4). - Every route of §7 is registered up front with a handler returning 501
not_implemented, and the utoipaApiDoclists them all, so M3 and M4 only fill in handler bodies in their own files (§11.2, §11.3). GET /healthreturns{status, server_version, schema_version, db_ok}, 503degradedwhenSELECT 1fails, unauthenticated, under 100 ms on the Pi (FR-20, §14.3.4 E-1).GET /setup/statusreturns{"initialized": bool}(AC-1.5).- Environment variables are exactly those of §10.4 with their defaults;
Europe/Stockholmis a code constant. Subcommands are exactlyserve,bootstrap,pair-device,rotate-epoch,healthcheck,migrate(§10.4); M2a implementsserve,migrate,healthcheck. server/Dockerfile: stage 1rust:<stable>-bookwormbuilds both binaries release--lockedforaarch64-unknown-linux-gnu(notarget-cpu=native); stage 2gcr.io/distroless/cc-debian12:nonrootarm64, binaries in/usr/local/bin/, user 10001, entrypointkvitt-server, commandserve(§10.3).- Every response carries
X-Kvitt-Server-Version; authenticated endpoints requireX-Kvitt-Protocol: 1else 426protocol_unsupported; unknown request fields ignored; missing/wrong fields 400invalid_request; path ids that are not UUIDs 404not_found; optional response fields present asnull(§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()outsidemainstart-up, nounsafe(CLAUDE.md). - M1 has landed (commit
c6df2ab):contracts/openapi.yamlexists 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.2not_implementedon 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.yamlmay be changed by M2a only for representational differences that do not change meaning (key order,$refnaming, 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'sCargo.toml,server/rust-toolchain.toml,server/Dockerfile,server/crates/kvitt-domain/**(after landing: contract-change plans only forkvitt-domain; M3, M4 and M9 may add dependency lines to a crate'sCargo.toml).kvitt-server:migrations/,src/{main,lib,config,error,router,openapi,auth,time}.rs(includingserveandmigrate),src/db/**,src/wire/**,src/api/{mod,health,setup}.rs,src/cli/{mod,healthcheck}.rs, and M2a's own tests (anytests/*.rswithout the prefixescore_,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 insrc/cli/{bootstrap,pair_device,rotate_epoch}.rs,src/tasks/mod.rs(astartfunction that does nothing yet),src/audit/andsrc/push/(theAuditWriterandPushQueuetraits with no-op implementations, D-015), andserver/crates/kvitt-import/(a crate that builds, with amain.rsthat 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.lockand everyCargo.tomlare 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-Versionand the §8.6 body limits are layers inrouter.rs. M3's rate limit is a layer insrc/api/rate_limit.rsattached at one line inrouter.rsthat M2a marks with a comment (attach a pass-through layer from the shell until M3 fills it) (D-015). - (e)
servecallstasks::startfromsrc/tasks/mod.rsonce after migrations; M4 ownssrc/tasks/**.AuditWriterandPushQueueare 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_dateand the day clamping of §9.5 inkvitt-domain(src/recurring.rs, pure, Rust only, unit tests for every AC-9.2 clamping case and thenullcases); report the exact signatures. - Also (§10.3):
rust-toolchain.tomlpins the exact current stable Rust release, and the Dockerfile's stage 1 usesrust:<that version>-bookworm.
5. Deliverables
server/Cargo.toml: workspace with memberscrates/kvitt-domain,crates/kvitt-server,crates/kvitt-import; shared lints (denyunsafe_code, clippyunwrap_used/expect_usedoutside 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 incontracts/test-vectors/.server/crates/kvitt-server/Cargo.toml,src/main.rs(CLI parsing, start-up),src/lib.rs(public modules forkvitt-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(utoipaApiDoc),src/auth.rs(device and admin extractors, §8.2, §8.4),src/time.rs(fixed RFC 3339 form,Europe/Stockholmconstant, injectable clock).src/db/mod.rs,src/db/pool.rs(pool with the §8.9 pragmas,BEGIN IMMEDIATEhelper),src/db/sequence.rs(nextserver_version).migrations/0001_init.sql: byte copy ofcontracts/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.rswithstart;src/audit/andsrc/push/with theAuditWriterandPushQueuetraits 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 equalscontracts/openapi.yaml;0001_init.sqlequalscontracts/schema/server-v1.sqlbyte for byte; every route returns 501not_implementedwith 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 likecontracts/examples/errors/<code>.json. server/crates/kvitt-import/: a crate that builds and depends onkvitt-domainandkvitt-server, with a usage-onlymain.rs(D-004).
6. Steps
- 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. Runtools/install-git-hooks.sh. - Read §2 in order. Confirm
contracts/exists onmain(M1 landed); if not, stop and report. - 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 Hubrustimage page; pin that exact version (for example1.NN.P, notstable) inrust-toolchain.tomland the same version in the Dockerfile'srust:<version>-bookworm. Look up current stable versions of each crate you add on crates.io / docs.rs and the digest ofgcr.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. - Write
kvitt-domaintest-first:tests/vectors.rsloads../../../contracts/test-vectors/{compute_split,compute_balances,propose_allocation,validate_settlement,over_settled_accounts}.jsonand checks every case; then implement §14.5.2 with the error order of the enums. Runcargo test -p kvitt-domain. - Write
config.rs,time.rs,error.rs,db/**,migrations/0001_init.sql(copy the bytes withcp contracts/schema/server-v1.sql server/crates/kvitt-server/migrations/0001_init.sql),wire/**. - Write
auth.rsandrouter.rswith 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). - Write
openapi.rswith utoipa annotations on every handler and wire type; make the generated document equalcontracts/openapi.yaml(parsed values, resolved point f). Only representational changes to the YAML are allowed; list each in the report. - Implement E-1, E-2,
serve(graceful SIGTERM drain up to 10 s, §10.4; callstasks::start),migrate,healthcheck; writekvitt-domain's recurring dates test-first. - Write the integration tests of §5 with an in-memory or temp-file SQLite and an injected clock.
- 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. - Run the gate (§7) in
server/. Commit onagent-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_mutationspruning (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 onmain. - 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_dateand clamping signatures; the marked rate-limit line. - Proposed decision entries and spec text (numbered from the start prompt's range), never written to
SPEC.mdordocs/decisions.mdby this agent; orTODO(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.