3ef2c15764
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
118 lines
16 KiB
Markdown
118 lines
16 KiB
Markdown
# 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.
|