From 6dce4c73a9d0ccf27f6a93e441a52dc7a467e7c1 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 17 Oct 2026 21:40:42 +0200 Subject: [PATCH] SPEC v0.4: M2a and M2b decisions, handoff to the real state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 4 +- PLAN-M2a.md | 2 + PLAN-M2b.md | 2 + PLAN-M3.md | 1 + PLAN-M5.md | 17 ++++---- PLAN-M7b.md | 1 + PLAN-M7c.md | 2 +- PLAN-M7d.md | 1 + PLAN-M7e.md | 1 + PLAN-M7f.md | 1 + SPEC.md | 108 ++++++++++++++++++++++++++++++---------------- docs/HANDOFF.md | 66 ++++++++++++++++------------ docs/decisions.md | 12 +++++- prompts/README.md | 6 +-- 14 files changed, 143 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 95a7f71..ecbcd45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,8 +121,8 @@ Enforced by an architecture check inside `check` where possible. Fill in from SP * **Fakes for every repository**, driven by scenarios, so screens render and screenshot-test without a device. Fakes never reach a release build. * **Fast-moving APIs:** confirm current usage from the official documentation at implementation time. Do not rely on memory. * **Licences first:** no vendor binary and no model file is committed, bundled or downloaded by a build before the owner has cleared its licence. -* **Server layering:** `kvitt-domain` has no I/O and depends only on `serde` (and `uuid` for ids); `kvitt-server` and `kvitt-import` depend on it, never the reverse. Money and split rules exist only in `kvitt-domain` and `:domain`, and both pass the same `contracts/test-vectors/*.json`. -* **No `unwrap()`/`expect()` in server non-test code** except in `main` start-up; no `unsafe`; errors map to the error codes of SPEC §14.3. +* **Server layering:** `kvitt-domain` has no I/O and depends only on `serde` (`serde_json` only as a dev-dependency for the vector tests; ids are plain strings); `kvitt-server` and `kvitt-import` depend on it, never the reverse. Money and split rules exist only in `kvitt-domain` and `:domain`, and both pass the same `contracts/test-vectors/*.json`. +* **No `unwrap()`/`expect()` in server non-test code**, start-up included; no `unsafe`; errors map to the error codes of SPEC §14.3. Enforced per crate by `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]` at every crate root, `main.rs` included (D-007); a new crate adds the same line. --- diff --git a/PLAN-M2a.md b/PLAN-M2a.md index 00ffc99..548c59a 100644 --- a/PLAN-M2a.md +++ b/PLAN-M2a.md @@ -1,5 +1,7 @@ # PLAN-M2a: Server skeleton and `kvitt-domain` +On record: landed on `main` as `fdf2093`. 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. diff --git a/PLAN-M2b.md b/PLAN-M2b.md index e4d1a3a..d7fb080 100644 --- a/PLAN-M2b.md +++ b/PLAN-M2b.md @@ -1,5 +1,7 @@ # PLAN-M2b: Android skeleton and `:domain` +On record: landed on `main` as `fad6fc9`. As built (SPEC v0.4, D-010 to D-014): Roborazzi 1.76.0 on Robolectric 4.17 for screenshots; `IncompleteCustomRangeException`; Room 3 with the entities, `KvittDatabase` v1 and the exported `1.json` in `:data`; JDK 25 test flags and `failOnNoDiscoveredTests` in `build-logic`; `minSdk` 36, `compileSdk`/`targetSdk` 37; the feature convention in `android/README.md`. `installDebug` on the phone is still outstanding (owner). Where this plan and SPEC differ, SPEC wins. + ## 1. Goal Create the Gradle project in `android/` with every module of §6.2, the version catalog, the architecture check, the `:core:model` UI contract and domain types, a `:domain` that passes every shared test vector including analytics, empty fakes, and an `:app` that launches to an empty themed screen. After it lands, M5 (data), M6 (design) and M7a to M7f (features) build against the same interfaces in parallel. diff --git a/PLAN-M3.md b/PLAN-M3.md index 8a64ce9..933ed81 100644 --- a/PLAN-M3.md +++ b/PLAN-M3.md @@ -33,6 +33,7 @@ Implement the REST surface of the server other than sync: invites and pairing, p - 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"). diff --git a/PLAN-M5.md b/PLAN-M5.md index a24304d..db19dda 100644 --- a/PLAN-M5.md +++ b/PLAN-M5.md @@ -1,7 +1,7 @@ # PLAN-M5: Android data layer ## 1. Goal -Implement `:data`: the Room database v1 with exported schema, DAOs, the Ktor API client and DTOs, the encrypted token store, the sync engine and its WorkManager workers, every real repository behind the `:core:model` interfaces, and the APK updater. The phone then works fully offline and syncs per §14.4, proven by tests with Ktor `MockEngine` and the shared JSON examples. +Implement `:data` on top of the Room 3 entities and `KvittDatabase` v1 that M2b created (D-012): DAOs, the Ktor API client and DTOs, the encrypted token store, the sync engine and its WorkManager workers, every real repository behind the `:core:model` interfaces, and the APK updater. The phone then works fully offline and syncs per §14.4, proven by tests with Ktor `MockEngine` and the shared JSON examples. ## 2. Read first 1. SPEC §14.2 Room entities (14.2.1 Mirrored tables, 14.2.2 Local-only tables, 14.2.3 Migrations, 14.2.4 UI contract). @@ -12,10 +12,10 @@ Implement `:data`: the Room database v1 with exported schema, DAOs, the Ktor API 6. SPEC §9.1 to §9.4, §9.6, §9.9 Online-only actions, §9.10. 7. SPEC §6.2 (Local DB, DI, HTTP, Background, Images, Secrets, Tests rows; module table). 8. SPEC §11.2 (row M5), §11.3, §14.7, §14.8. -9. Files: `contracts/examples/**`, `android/core/model/**`, `android/domain/**`, `android/core/fakes/**`, `android/build-logic/**`, `android/gradle/libs.versions.toml`, `CLAUDE.md` (BUILD AND VERIFY, ARCHITECTURE RULES, ANTI-PATTERNS, THE PHONE). +9. Files: `android/data/**` as M2b left it (`build.gradle.kts`, `db/Entities.kt`, `db/KvittDatabase.kt`, `schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`), `android/README.md`, `contracts/examples/**`, `android/core/model/**`, `android/domain/**`, `android/core/fakes/**`, `android/build-logic/**`, `android/gradle/libs.versions.toml`, `CLAUDE.md` (BUILD AND VERIFY, ARCHITECTURE RULES, ANTI-PATTERNS, THE PHONE). ## 3. Already established -- `KvittDatabase` in package `se.hagstedt.kvitt.data.db`, file `kvitt.db`, version 1, `exportSchema = true` to `android/data/schemas/` (committed; a contract file once landed); no foreign keys; names identical to the server's; camelCase properties with `@ColumnInfo(name = "snake_case")`; server-only columns not mirrored; timestamps `String`, dates `String`, money `Long`, bp `Int`, booleans `Boolean`; `local_updated_at` on every write (§14.2). +- Room 3 (`androidx.room3` 3.0.3, KSP only, `SQLiteDriver` with `sqlite-bundled`; migrations take an `SQLiteConnection`) (D-012). M2b already created every entity of §14.2.1 and §14.2.2 and `KvittDatabase` (package `se.hagstedt.kvitt.data.db`, file `kvitt.db`, version 1, `exportSchema = true`); the exported `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` is a contract file (§14.8). M5 adds DAOs to the existing database and does not change the entities or the exported schema; a change it needs is a contract request to the orchestrator (spec bump, decision, next Room version 2 with a migration and migration test). Established for those entities: no foreign keys; names identical to the server's; camelCase properties with `@ColumnInfo(name = "snake_case")`; server-only columns not mirrored; timestamps `String`, dates `String`, money `Long`, bp `Int`, booleans `Boolean`; `local_updated_at` on every write (§14.2). - Entity classes, tables, columns, keys and indices exactly as §14.2.1 and §14.2.2; per-phone preferences in DataStore `prefs.preferences_pb` (§14.2.2). - Local write semantics: `pending = true`, new UUIDv7 `pending_mutation_id`, `updated_at` = now in the fixed form (and `created_at` on create), local delete sets `deleted_at` = `updated_at` = now; pending count = pending expenses + pending settlements (§14.2.1). - Pull per page in one Room transaction: epoch check, apply arrays in the fixed order, skip rows with local `server_version` ≥ incoming and pending expenses/settlements, upsert and replace children, audit insert-or-ignore, cursor and epoch update; repeat while `has_more`; then `last_success_at` (§14.4.2). @@ -25,6 +25,7 @@ Implement `:data`: the Room database v1 with exported schema, DAOs, the Ktor API - Token: Android Keystore AES-256-GCM key alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, non-exportable; ciphertext and IV in DataStore `auth.preferences_pb`; base URL, user id, device id in plain text beside it; never logged or put in a URL (§8.2). - Online-only actions return `OnlineResult.Offline` without a validated network or within 30 s of a network error; a REST network failure changes nothing locally (§9.9). - Balances, previews, proposals and analytics are computed in `:data` with `:domain` and include pending changes; features never call `:domain` (§6.2, AC-10.4, AC-14.1). +- Readings fixed in v0.4 (§14.2.4): `FieldError.error` is a §14.5.3 token, `required`, or `validation_failed` for a §9.6 rule without a domain token; `analytics()` resolves a missing `CUSTOM` date as today before calling `resolveRange` (`AnalyticsState` has no error field; `IncompleteCustomRangeException`, D-011, is never surfaced); `DrillDownState.title` is the unlocalised parts joined by ", " (category custom name or lowercase default key, account name, bucket key); a `SettlementRow` in an account's ledger carries the allocation to that account, elsewhere the settlement total, with `accountCount` of the whole settlement (AC-11.7). - Editor defaults from DataStore: last used account if active else oldest active; last used category in that account if active else `other` (AC-8.6). - APK updater: release builds call `GET /apk/latest` on cold start and at most every 6 hours while resumed; download to cache, verify size and SHA-256, install through `PackageInstaller`; failure deletes the file; debug builds never check (FR-16). - Pictures through Coil with an auth header, cached by user id and `picture_version` (AC-4.6, §6.2). @@ -37,9 +38,9 @@ Also from §14.7 (row `android/settings.gradle.kts`, `android/build.gradle.kts`, Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator. ## 5. Deliverables -- `android/data/build.gradle.kts`: dependencies `:core:model`, `:domain`, Room, KSP, Ktor, kotlinx.serialization, WorkManager, DataStore, Hilt, Coil. -- `android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/**`: `KvittDatabase`, every entity of §14.2.1 and §14.2.2, DAOs. -- `android/data/schemas/**`: the exported Room schema for version 1. +- `android/data/build.gradle.kts` (exists, with Room 3 and KSP): add dependencies `:core:model`, `:domain`, Ktor, kotlinx.serialization, WorkManager, DataStore, Hilt, Coil. +- `android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/**`: DAOs, registered on the existing `KvittDatabase` (entities as M2b wrote them). +- `android/data/schemas/**`: unchanged; the build must re-export an identical `1.json` (adding DAOs does not change it). - `.../data/api/**`: Ktor client with the §14.3.1 headers, DTOs for every wire object and endpoint body, error mapping to §14.3.2 codes. - `.../data/auth/**`: Keystore token store, session state. - `.../data/sync/**`: sync engine (push, pull, epoch recovery, rejection records), debounce, WorkManager workers. @@ -54,7 +55,7 @@ Edit nothing outside these paths; a needed change elsewhere is a contract reques 1. Worktree. From the main checkout `/Users/jhag/projects/kvitt`: `git worktree add .claude/worktrees/agent-m5 -b agent-m5 main`, then in `.claude/worktrees/agent-m5`: `git reset --hard main`. Copy `android/local.properties` from the main checkout (add `sdk.dir=/opt/homebrew/share/android-commandlinetools` if missing; never overwrite or commit). Run `tools/install-git-hooks.sh`. 2. Read §2 in order. Confirm M2b has landed (`android/core/model/` on `main`); if not, stop and report. 3. Run `python3 tools/dependency-report/latest.py`; record the lines. Confirm current Room, KSP, Ktor, WorkManager, DataStore and Android Keystore APIs from official documentation (developer.android.com, ktor.io); any new catalog entry at its latest stable version; record versions. -4. Entities and DAOs; export the schema; check it against §14.2.1 column by column. +4. Check M2b's entities and `1.json` against §14.2.1 and §14.2.2 column by column (report any mismatch as a contract request, do not fix it silently); write the DAOs; confirm `1.json` is unchanged after the build. 5. DTOs and the round-trip test over every `contracts/examples/**` file (decode, re-encode, compare JSON values) before writing the client. 6. Ktor client and error mapping; token store. 7. Sync engine test-first with `MockEngine`: each pull step, each push result case, epoch recovery, `Unrecognised` and revoked paths, clock skew, no-progress stop, the AC-15.6 scenario. @@ -81,7 +82,7 @@ From SPEC §11.2, verbatim: "none". - Versions added or changed, with sources; dependency-report lines. - Mapping of §14.4.2 and §14.4.5 rules to test names. - Numbering used or `TODO(spec)` occurrences. -- Contract requests (any missing field in §14.2.4), open questions, spec gaps (with section numbers). +- Contract requests (any missing field in §14.2.4, any entity or schema change), 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. diff --git a/PLAN-M7b.md b/PLAN-M7b.md index 655fc91..e3afb0e 100644 --- a/PLAN-M7b.md +++ b/PLAN-M7b.md @@ -28,6 +28,7 @@ Build `:feature:home` (Home S-4, sync status S-16, update banner and dialog S-17 - No placeholder text in previews or screenshots; goldens are committed; open every changed PNG before committing (CLAUDE.md). - Home shows the global balance from the viewer's side (" owes you X", "You owe X", "All settled up"), every active account with its balance, archived accounts in a collapsed "Archived (n)" section, "Settle up" when non-zero, the sync line with "Last synced …" and pending count, and the update banner (AC-10.1, AC-10.2, AC-15.4, §13.3 S-4). - The account screen shows its balance ("Settled up" at zero) and expenses and settlements interleaved by date, grouped by month with household-spend month totals; rows per AC-8.10 and AC-11.7 (AC-10.3, §9.3, §13.3 S-5). +- A settlement row in the account's list shows the settlement's allocation to that account, not its total, with the account count of the whole settlement (AC-11.7, §14.2.4 `SettlementRow`, v0.4); the repository fills `amountOre` that way. - Account editor: ratios entered as percent with up to two decimals, the second user's value always 100 minus the first, stored as bp; starts at 50/50; online only (AC-6.1, AC-6.7). Archive/unarchive any time; delete only when `deletable` (AC-6.4, AC-6.5). - Sync status lists pending changes, "Changes not synced" from `sync_rejections` with the §14.4.6 explanations, the clock-skew warning, and "Sync now" (AC-15.5, §9.10, §13.3 S-16). - Update: banner with version name; first time, a dialog explaining "Install unknown apps"; download progress; "Download failed, try again" (AC-16.2 to AC-16.4). diff --git a/PLAN-M7c.md b/PLAN-M7c.md index d1a082a..497e33a 100644 --- a/PLAN-M7c.md +++ b/PLAN-M7c.md @@ -30,7 +30,7 @@ Build `:feature:expense`: the full-screen expense editor (S-7) and the expense d - Editor is a full screen, not a bottom sheet (§13.2). Amount input accepts digits, one decimal separator (`,` or `.`), at most two decimals, no sign, converted to öre by string parsing, never floating point (§9.6). - Defaults: account = last used on this phone if active else oldest active; category = last used in that account if active else `other`; split mode `default`; payer = current user; date = today in the phone's zone (AC-8.3, AC-8.4, AC-8.6). Defaults come from `ExpenseEditorOptions`. - Split modes `default` ("Account default 60/40"), `ratio`, `exact` (type one share, the other is amount minus it, read-only; a share larger than the amount blocks saving inline), `full` (one bearer); changing account in `default` re-copies the ratio; live preview from `ExpenseRepository.preview` (AC-8.5, AC-8.7, AC-8.8, §13.3 S-7). -- Validation errors come back as `SaveResult.Invalid` with §14.5.3 tokens or `required`; deleting asks for confirmation (AC-8.9). +- Validation errors come back as `SaveResult.Invalid` with §14.5.3 tokens, `required`, or `validation_failed` for a §9.6 limit without a domain token (lengths, dates) (§9.6, §14.2.4); deleting asks for confirmation (AC-8.9). - Detail shows amount, description, category, account, date, payer, shares and mode, notes, "Recurring: