Files
jacobh2 6dce4c73a9 SPEC v0.4: M2a and M2b decisions, handoff to the real state
Records the decisions proposed in the M2a (fdf2093) and M2b (fad6fc9)
reports: D-005 OpenAPI equality normalisations and CommonRules, D-006
bp_sum_not_10000 token, D-007 per-crate no-unwrap deny, D-010 Roborazzi,
D-011 IncompleteCustomRangeException, D-012 Room 3 with M2b's entities and
1.json as a contract file, D-013 JDK 25 Robolectric flags, D-014
failOnNoDiscoveredTests (D-008, D-009 unused). Clarifications in §6, §8.2,
§8.6, §9.6, §10.3, §10.4, §11, §14.2, §14.3, §14.5, §14.7, §14.8 and
AC-11.7. PLAN-M5 builds on the existing entities; v0.4 readings added to
PLAN-M3, M5, M7b to M7f. HANDOFF rewritten: next free D-040, spec v0.5,
server schema 2, Room schema 2.

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

103 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PLAN-M3: Server core API
## 1. Goal
Implement the REST surface of the server other than sync: invites and pairing, profile and pictures, devices, accounts, categories, recurring templates, the settlement proposal, APK serving, the invite landing page, rate limiting, and the `bootstrap` and `pair-device` subcommands. Every endpoint is covered by integration tests for each error code it can return.
## 2. Read first
1. SPEC §7 API outline.
2. 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 (E-2 to E-30, E-33), 14.3.5 Examples as contract.
3. SPEC §4 FR-1 Server bootstrap, FR-2 Invites and pairing, FR-3 Devices and revocation, FR-4 Profiles, FR-5 Household gate, FR-6 Expense accounts, FR-7 Categories, FR-9 Recurring expenses (AC-9.1, AC-9.7, AC-9.8), FR-11 AC-11.8, FR-16 AC-16.1.
4. SPEC §8.2 Device tokens, §8.3 Invite codes, §8.4 Authorization rules, §8.5 Transport (`X-Forwarded-For`), §8.6 Rate limiting and input limits, §8.7 Privacy and logging.
5. SPEC §9.4 Settlement allocation proposal, §9.5 Recurring generation (resume rule, `next_occurrence_date`, due-period generation on create), §9.6 Limits and validation, §9.7 Icon and colour tokens, §9.10 Other edge cases.
6. SPEC §10.4 Server configuration and CLI (`bootstrap`, `pair-device`), §10.6 APK hosting and release (`latest.json`, file name pattern).
7. SPEC §14.1 SQLite schema v1 and server-side rules; §14.5.2 Functions (`propose_allocation`).
8. SPEC §11.2 (row M3), §11.3, §14.7, §14.8; §6.3 "Server seams (D-015)"; §13.3 "Invite landing page" copy; `docs/decisions.md` D-004, D-015.
9. Files: `contracts/openapi.yaml`, `contracts/examples/**`, the M2a code under `server/` (router, error, auth, wire, db), `CLAUDE.md` (BUILD AND VERIFY, ARCHITECTURE RULES, ANTI-PATTERNS).
## 3. Already established
- Routes, the utoipa `ApiDoc`, error type, auth extractors, wire objects, DB pool and sequence exist from M2a; M2a created M3's files as shells with 501 handlers and stub subcommands, and M3 fills in the bodies (§11.3, D-004).
- Seams (D-015): the protocol check, `X-Kvitt-Server-Version` and the §8.6 body limits are M2a's layers in `router.rs`; M3's rate limit is a layer in `src/api/rate_limit.rs` attached at the one line in `router.rs` that M2a marked (the only line M3 edits there). Audit entries and pushes go through the `AuditWriter` and `PushQueue` traits M2a defined (no-op until M4 replaces the implementations); M3 never writes `audit_log` rows or FCM requests itself. `kvitt-domain` has `next_occurrence_date` and the day clamping (§14.5.2).
- `bootstrap`: no users → cancel any pending `admin_setup`, create a new one, print code, link `<KVITT_PUBLIC_URL>/i/<CODE>`, expiry, exit 0; users exist → `already initialized` on stderr, exit 2 (AC-1.1 to AC-1.3, §10.4). `pair-device --user <id>`: device invite, exit 3 for unknown user (AC-1.6, §10.4).
- Invite codes: 12 Crockford base32 characters, displayed `XXXX-XXXX-XXXX`, normalised (uppercase, strip `-` and spaces, `O`→`0`, `I`/`L`→`1`), stored as SHA-256 hex, single-use, 72 h expiry, cancellable; failures are `invite_invalid` without saying why (§8.3, AC-2.3).
- Tokens: `kvt_` + base64url(32 CSPRNG bytes) without padding, 47 characters, stored as SHA-256 hex; never logged (§8.2).
- Partner invite refused with `household_full` at two users; a new partner invite cancels a pending one (AC-2.1). Accept returns token, user, device, household (AC-2.4, E-3).
- Landing page `GET /i/{code}`: Swedish if `Accept-Language` prefers `sv`, else English; app name, "Download Kvitt" to the latest APK, "Open in Kvitt" to `kvitt://invite?server=<urlencoded KVITT_PUBLIC_URL>&code=<CODE>`, code in groups of 4, one sentence of instructions, all copy exactly as the §13.3 "Invite landing page" table (en "Install Kvitt, then open this link again on your phone.", sv "Installera Kvitt och öppna sedan den här länken igen på din telefon."); no validity hint, no personal data; any 1 to 32 character code gets 200, else 404 HTML (AC-2.5, E-4).
- Rate limits: unauthenticated 60/min per IP, authenticated 600/min per device, 429 `rate_limited` with `Retry-After`; more than 10 failed accepts per IP in 60 minutes → `rate_limited` for the rest of the window; in memory; `X-Forwarded-For` trusted only from the Caddy peer (`KVITT_TRUSTED_PROXY`) (§8.6, AC-2.12, §8.5, §10.4).
- Pictures: JPEG/PNG/WebP up to 5 MiB and 8000 × 8000, EXIF orientation applied, centre-crop square, JPEG q85 at 512 and 128, metadata stripped, `picture_version` incremented, files `pictures/<user_id>/<v>-512.jpg` and `-128.jpg`, older versions deleted after success; E-11 headers `ETag`, `Cache-Control: private, max-age=31536000, immutable`, `size` 128|512 default 512 (AC-4.4, §10.4, E-9 to E-11).
- Household gate: account create, template create and any expense or settlement mutation → `household_incomplete` below two users (AC-5.2).
- Accounts: names unique among non-deleted, case-insensitive after trim (Unicode lowercase) via `name_key`; delete only when unreferenced (`account_not_empty`), soft delete; ratio change affects only later expenses (FR-6).
- Categories: icon and colour validated against §9.7 lists; new `sort_order` = max + 10; `name: null` resets a default name, `category_is_default` on a non-default; delete blocked by use (`category_in_use`) or default key (`category_is_default`); no uniqueness (FR-7, E-23 to E-27).
- Recurring templates: E-28 generates an already-due period before responding; E-29 `split_mode` and `splits` together; resume does not backfill; `next_occurrence_date` per §9.5 via `kvitt-domain` (§14.3.4, §9.5).
- Recurring generation (D-015): M3 writes the one public function that generates the due periods of one template per §9.5 (candidate periods, one transaction per candidate, split resolution, audit `create` with `source` `recurring` through `AuditWriter`, `last_generated_period` bump, `recurring_expense_created` through `PushQueue`) in `src/api/recurring.rs`; E-28 and E-29 call it, and M4's scheduler calls it for every template. M4 lands after M3.
- Every REST write that changes a synced row takes a new `server_version` in a `BEGIN IMMEDIATE` transaction and returns the row in wire form (§7, §14.1).
- E-33 uses `kvitt-domain::propose_allocation` over non-deleted rows, `exclude_settlement_id` honoured (AC-11.8, E-33).
- APK: `GET /apk/latest` reads `apk/latest.json` on each request; `GET /apk/files/{file_name}` only for `^kvitt-[0-9]+\.apk$` that exists, `application/vnd.android.package-archive`, `Content-Length` (§10.6, E-5, E-6).
- `last_seen_at`/`app_version_code` update at most hourly (§8.2); devices never deleted (AC-3.5).
- That hourly update (M2a's, in `auth.rs`) sets `app_version_code` only when `X-Kvitt-App-Version` is present and parses; `GET /setup/status` (M2a's) is true when an admin user exists; a known path with an unsupported method answers 405 with an empty body (§8.2, §14.3.1, §14.3.4, v0.4).
## 4. Owned paths
From SPEC §14.7 (v0.3): `kvitt-server`: `src/api/{invites,landing,me,pictures,devices,accounts,categories,recurring,proposal,apk,rate_limit}.rs`, `src/cli/{bootstrap,pair_device}.rs`, `tests/core_*.rs` (owner M3, shells from M2a; after landing: "later server plans").
Also from §14.7: M3 may add dependency lines to a crate's `Cargo.toml` (second to land resolves `Cargo.lock` on rebase), may add one line per new module to `mod.rs` files it needs, and edits the one marked rate-limit line in `router.rs`.
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.
Resolved open points (SPEC v0.3; the start prompt may repeat them):
- (a) M3 writes the recurring generation function (above) and calls the `AuditWriter`/`PushQueue` traits; M2a provides `next_occurrence_date` and the clamping in `kvitt-domain`; M4 replaces the trait implementations and its scheduler calls M3's function; M4 lands after M3 (D-015).
- (b) E-2 is implemented by M2a (`src/api/setup.rs`); M3 only tests it.
- (c) The rate limit is a layer in `src/api/rate_limit.rs`, attached at the marked line in `router.rs`; the body limits are M2a's layers (D-015).
- (d) The landing page copy is the §13.3 "Invite landing page" table, in `src/api/landing.rs`.
## 5. Deliverables
- `src/api/invites.rs`: E-3, E-12, E-13, E-14.
- `src/api/landing.rs`: E-4 HTML in sv and en.
- `src/api/me.rs`: E-7, E-8.
- `src/api/pictures.rs`: E-9, E-10, E-11.
- `src/api/devices.rs`: E-15, E-16, E-17.
- `src/api/accounts.rs`: E-18 to E-22.
- `src/api/categories.rs`: E-23 to E-27.
- `src/api/recurring.rs`: E-28 to E-30 and the public recurring generation function (D-015).
- `src/api/proposal.rs`: E-33.
- `src/api/apk.rs`: E-5, E-6.
- `src/api/rate_limit.rs`: §8.6 rate limits and AC-2.12, as a layer for the marked line in `router.rs`.
- `src/cli/bootstrap.rs`, `src/cli/pair_device.rs`: FR-1 subcommands.
- `tests/core_*.rs`: one or more integration test files per endpoint group covering success and every error code listed for each endpoint in §14.3.4, responses compared with `contracts/examples/`; and every generation case of §9.5 through the generation function (catch-up of several periods, clamping 29/30/31 and February 2027, mode `default` resolving the current account ratio, other modes from the template splits, idempotency on the unique index, a deleted generated expense not recreated, resume without backfill, archived account continues, `next_occurrence_date`), with recording fakes of `AuditWriter` and `PushQueue`.
## 6. Steps
1. Worktree. From the main checkout `/Users/jhag/projects/kvitt`: `git worktree add .claude/worktrees/agent-m3 -b agent-m3 main`, then in `.claude/worktrees/agent-m3`: `git reset --hard main`. No git-ignored files are needed; copy none. Run `tools/install-git-hooks.sh`.
2. Read §2 in order. Confirm M2a has landed (`server/crates/kvitt-server/src/router.rs` exists on `main`); if not, stop and report.
3. Versions: any crate added (for example image decoding, HTML templating, a rate limiter) at its latest stable version from crates.io / docs.rs; confirm current API usage from official docs; record each.
4. Build the test harness first in `tests/core_common.rs` or equivalent (temp SQLite, injected clock, seeded two-user household helper, request helper that sends `X-Kvitt-Protocol: 1`).
5. Implement in this order, each with its tests before moving on: `bootstrap` and `pair-device`; E-3 accept (all three kinds) and E-12 to E-14; E-7, E-8; E-15 to E-17; E-18 to E-22; E-23 to E-27; E-28 to E-30 with the generation function (resolved point a); E-33; E-9 to E-11; E-5, E-6; E-4; rate limiting (resolved point c).
6. For each endpoint, test every error code §14.3.4 lists for it, plus `household_incomplete` where AC-5.2 applies and `forbidden` for member on admin routes.
7. Compare response bodies with `contracts/examples/<E-id>-<name>/response.json` by JSON value (ids and timestamps normalised as the examples allow).
8. Check logs from one test run contain no token, code, name, description, note or amount (§8.7).
9. Run the gate (§7). Commit on `agent-m3`, `git rebase main` (resolve `Cargo.lock` if M4 landed first), rerun the gate. Never merge, push or remove the worktree.
## 7. Gate
From SPEC §11.2 (v0.3), verbatim: "Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, `next_occurrence_date`)".
Command, from `server/`: `cargo fmt --check && cargo clippy -- -D warnings && cargo test`.
Green means: all three exit 0 in this session; `test result:` lines show 0 failed; the report contains a table endpoint × error code → test name with no empty cell for E-2 to E-30 and E-33, and a table §9.5 generation case → test name.
## 8. HUMAN steps
From SPEC §11.2, verbatim: "none".
## 9. Out of scope
- E-31, E-32 sync, the real audit writer and diff, push sender, the scheduler loop, `src/tasks/**`, `rotate-epoch`, `sync_mutations` pruning (M4). The generation function is M3's (resolved point a).
- Router, error type, auth extractor, wire types, migrations (M2a; contract requests only).
- The importer (M9); `infra/` (M2c-dev, M2c-pi); any Android work.
- Semantic changes to `contracts/**`.
## 10. Report
- Branch `agent-m3`, head commit hash after rebasing on `main`.
- Gate output: fmt and clippy exit status, every `test result:` line.
- The endpoint × error code → test table.
- Versions of crates added, with sources.
- How resolved points (a) to (d) were applied; the generation function's signature (M4 calls it).
- Proposed decision entries and spec text (never written to `SPEC.md` or `docs/decisions.md` by this agent) or `TODO(spec)` occurrences.
- Contract requests (anything needed in M2a's files), open questions, spec gaps (with section numbers).
## 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.