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>
This commit is contained in:
2026-10-17 21:40:42 +02:00
parent 06f48ef9e9
commit 6dce4c73a9
14 changed files with 143 additions and 81 deletions
+2 -2
View File
@@ -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. * **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. * **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. * **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`. * **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** except in `main` start-up; no `unsafe`; errors map to the error codes of SPEC §14.3. * **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.
--- ---
+2
View File
@@ -1,5 +1,7 @@
# PLAN-M2a: Server skeleton and `kvitt-domain` # 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 ## 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. 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
View File
@@ -1,5 +1,7 @@
# PLAN-M2b: Android skeleton and `:domain` # 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 ## 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. 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.
+1
View File
@@ -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). - 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). - 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). - `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 ## 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"). 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").
+9 -8
View File
@@ -1,7 +1,7 @@
# PLAN-M5: Android data layer # PLAN-M5: Android data layer
## 1. Goal ## 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 ## 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). 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. 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). 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. 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 ## 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). - 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). - 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). - 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). - 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). - 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). - 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). - 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). - 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). - 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. Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator.
## 5. Deliverables ## 5. Deliverables
- `android/data/build.gradle.kts`: dependencies `:core:model`, `:domain`, Room, KSP, Ktor, kotlinx.serialization, WorkManager, DataStore, Hilt, Coil. - `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/**`: `KvittDatabase`, every entity of §14.2.1 and §14.2.2, DAOs. - `android/data/src/main/kotlin/se/hagstedt/kvitt/data/db/**`: DAOs, registered on the existing `KvittDatabase` (entities as M2b wrote them).
- `android/data/schemas/**`: the exported Room schema for version 1. - `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/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/auth/**`: Keystore token store, session state.
- `.../data/sync/**`: sync engine (push, pull, epoch recovery, rejection records), debounce, WorkManager workers. - `.../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`. 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. 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. 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. 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. 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. 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. - Versions added or changed, with sources; dependency-report lines.
- Mapping of §14.4.2 and §14.4.5 rules to test names. - Mapping of §14.4.2 and §14.4.5 rules to test names.
- Numbering used or `TODO(spec)` occurrences. - 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 ## 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. Decision ids, spec version and schema version are assigned by the orchestrator in the start prompt; use `TODO(spec)` if none was given.
+1
View File
@@ -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). - 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 ("<Partner> owes you X", "You owe <Partner> 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). - Home shows the global balance from the viewer's side ("<Partner> owes you X", "You owe <Partner> 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). - 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). - 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). - 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). - 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).
+1 -1
View File
@@ -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). - 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`. - 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). - 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: <template>" when generated, and the timeline: newest last, one line per entry, field names in the UI language, ids resolved, pending line "Your change is waiting to sync"; a deleted state when opened from a deletion notification (AC-8.11, AC-12.4, AC-12.5, §13.3 S-8). - Detail shows amount, description, category, account, date, payer, shares and mode, notes, "Recurring: <template>" when generated, and the timeline: newest last, one line per entry, field names in the UI language, ids resolved, pending line "Your change is waiting to sync"; a deleted state when opened from a deletion notification (AC-8.11, AC-12.4, AC-12.5, §13.3 S-8).
## 4. Owned paths ## 4. Owned paths
+1
View File
@@ -29,6 +29,7 @@ Build `:feature:settle`: settle up, global and per account (S-9), and the settle
- Per-account settle-up: exactly one fixed allocation, amount defaulting to the absolute account balance, direction to the user who owes in that account (AC-11.4). - Per-account settle-up: exactly one fixed allocation, amount defaulting to the absolute account balance, direction to the user who owes in that account (AC-11.4).
- Over-settling is allowed, warned per row ("This settles more than is owed in <account>") and repeated in the confirm dialog (AC-11.5). - Over-settling is allowed, warned per row ("This settles more than is owed in <account>") and repeated in the confirm dialog (AC-11.5).
- Settlements can be created, edited and deleted offline; editing recomputes with the edited settlement excluded (AC-11.6). - Settlements can be created, edited and deleted offline; editing recomputes with the edited settlement excluded (AC-11.6).
- 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 (note length, date) (§9.6, §14.2.4).
- The proposal may leave accounts non-zero when the payer is owed somewhere; that is intended (§9.4 Consequences). - The proposal may leave accounts non-zero when the payer is owed somewhere; that is intended (§9.4 Consequences).
- Detail shows payer, recipient, amount, date, note, allocations per account, and the timeline (§13.3 S-10, AC-12.4, AC-12.5). - Detail shows payer, recipient, amount, date, note, allocations per account, and the timeline (§13.3 S-10, AC-12.4, AC-12.5).
+1
View File
@@ -30,6 +30,7 @@ Build `:feature:analytics`: the analytics screen with range, granularity, measur
- Charts: stacked bars per bucket coloured by group with legend; category breakdown and account breakdown as horizontal bars sorted descending with amount and percent; range total above the charts; settlements never count (AC-14.6). Charts are `:core:design` components (M6). - Charts: stacked bars per bucket coloured by group with legend; category breakdown and account breakdown as horizontal bars sorted descending with amount and percent; range total above the charts; settlements never count (AC-14.6). Charts are `:core:design` components (M6).
- Every bucket appears, empty ones as zero; percentages rounded half-up to one decimal and may not sum to 100 (AC-14.7, AC-14.10). Values come computed from the repository. - Every bucket appears, empty ones as zero; percentages rounded half-up to one decimal and may not sum to 100 (AC-14.7, AC-14.10). Values come computed from the repository.
- Tapping a bar or a breakdown row opens S-12 with the expenses behind that number; title names the filter ("Mat & hushåll, oktober 2026") (AC-14.8, §13.3 S-12). - Tapping a bar or a breakdown row opens S-12 with the expenses behind that number; title names the filter ("Mat & hushåll, oktober 2026") (AC-14.8, §13.3 S-12).
- `DrillDownState.title` is a `String` of unlocalised parts; S-12 formats its visible title itself from the arguments it passed to `drillDown` (the category or account, with default category names from resources, and the bucket key through `DateFormatter`) (§14.2.4, v0.4). `AnalyticsState` has no error field: the custom-range editor never applies a range with a missing start or end (the repository would treat a missing date as today) (§14.2.4, §14.5.4).
- Archived accounts and categories are included; deleted expenses excluded (AC-14.9). - Archived accounts and categories are included; deleted expenses excluded (AC-14.9).
## 4. Owned paths ## 4. Owned paths
+1
View File
@@ -30,6 +30,7 @@ Build `:feature:settings`: the settings hub (S-13), profile (S-14), categories (
- Profile: own name and picture only, from the Android photo picker, online only (AC-4.2, AC-4.3, §9.9). - Profile: own name and picture only, from the Android photo picker, online only (AC-4.2, AC-4.3, §9.9).
- Categories: list with icon, colour, name and an archived section; editor with "Reset name" for defaults, the 30 icons and 12 colour swatches of §9.7; reorder by drag; delete blocked with the reason (`category_in_use`, `category_is_default`), archive offered instead; all online only (FR-7, §13.3 S-15). - Categories: list with icon, colour, name and an archived section; editor with "Reset name" for defaults, the 30 icons and 12 colour swatches of §9.7; reorder by drag; delete blocked with the reason (`category_in_use`, `category_is_default`), archive offered instead; all online only (FR-7, §13.3 S-15).
- Recurring: list with description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 except date, plus day of month, first month (default per AC-9.8, up to 12 months back), optional last month, pause switch; online only (FR-9, §13.3 S-19). - Recurring: list with description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 except date, plus day of month, first month (default per AC-9.8, up to 12 months back), optional last month, pause switch; online only (FR-9, §13.3 S-19).
- Every settings write is online: a refused value comes back as `OnlineResult.Failed` with the §14.3.2 code and `field` (for example `validation_failed` on `name`), shown inline at that field (§9.6, §14.2.4, §14.3.2).
- Devices: rows per AC-3.1 ("This device" marked); revoke with confirmation; the current device cannot be revoked; admin sees all, member own (AC-3.1, AC-3.2). - Devices: rows per AC-3.1 ("This device" marked); revoke with confirmation; the current device cannot be revoked; admin sees all, member own (AC-3.1, AC-3.2).
- Invites (admin): "Invite partner" only before the partner joins, "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry and an Android share action; pending invites with cancel (AC-2.1, AC-2.2, AC-2.11, §13.3 S-21). - Invites (admin): "Invite partner" only before the partner joins, "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry and an Android share action; pending invites with cancel (AC-2.1, AC-2.2, AC-2.11, §13.3 S-21).
- About: version name and code, server URL, server version from `/health` (null offline), open-source licences, "Check for update" (§13.3 S-22). - About: version name and code, server URL, server version from `/health` (null offline), open-source licences, "Check for update" (§13.3 S-22).
+70 -38
View File
@@ -1,6 +1,6 @@
# Kvitt: specification # Kvitt: specification
Version **0.3** (2026-10-17). Schema version **1**. Sync protocol version **1**. Push payload version **1**. Version **0.4** (2026-10-17). Schema version **1**. Sync protocol version **1**. Push payload version **1**.
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it. This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it.
@@ -225,7 +225,7 @@ The server starts with an empty database. The household is created from the comm
* AC-11.4 Per-account settle-up (from the account screen) creates a settlement with exactly one allocation for that account; the amount defaults to the absolute account balance and the direction to "the user who owes in this account pays". * AC-11.4 Per-account settle-up (from the account screen) creates a settlement with exactly one allocation for that account; the amount defaults to the absolute account balance and the direction to "the user who owes in this account pays".
* AC-11.5 An allocation larger than the payer's current debt in that account is allowed (over-settling flips the account's sign) and is marked with a warning per row ("This settles more than is owed in <account>"). The confirm dialog repeats the warning. * AC-11.5 An allocation larger than the payer's current debt in that account is allowed (over-settling flips the account's sign) and is marked with a warning per row ("This settles more than is owed in <account>"). The confirm dialog repeats the warning.
* AC-11.6 Settlements can be created, edited and deleted (soft delete) offline, by either user, like expenses. When editing, the proposal and warnings are computed with the edited settlement excluded from the balances. * AC-11.6 Settlements can be created, edited and deleted (soft delete) offline, by either user, like expenses. When editing, the proposal and warnings are computed with the edited settlement excluded from the balances.
* AC-11.7 A settlement row in lists shows "<Payer> paid <Recipient> X" and the account count when more than one allocation exists. * AC-11.7 A settlement row in lists shows "<Payer> paid <Recipient> X" and the account count when more than one allocation exists. In an account's list (S-5) X is the settlement's allocation to that account, not the settlement total; everywhere else X is the total.
* AC-11.8 The server's `POST /settlements/proposal` returns exactly what the phone's `propose_allocation` returns for the same server state (verified by shared vectors). The phone always computes locally; the endpoint exists for parity tests and server-side tooling. * AC-11.8 The server's `POST /settlements/proposal` returns exactly what the phone's `propose_allocation` returns for the same server state (verified by shared vectors). The phone always computes locally; the endpoint exists for parity tests and server-side tooling.
### FR-12 Audit log and timeline ### FR-12 Audit log and timeline
@@ -391,9 +391,9 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2).
| Concern | Choice | Why | | Concern | Choice | Why |
|---|---|---| |---|---|---|
| Language | Kotlin 2.x, latest stable | Owner's requirement | | Language | Kotlin 2.x, latest stable | Owner's requirement |
| UI | Jetpack Compose, Material 3; Material 3 Expressive components where they are stable | Native, modern, screenshot-testable | | UI | Jetpack Compose, Material 3; Material 3 Expressive components where they are stable. The Material 3 artifact is pinned explicitly in the catalog (1.4.0 at M2b) alongside the Compose BOM | Native, modern, screenshot-testable |
| Min / target SDK | `minSdk` 36 (Android 16); `targetSdk` and `compileSdk` the latest stable API at implementation time | Both phones run Android 16; no legacy paths | | Min / target SDK | `minSdk` 36 (Android 16); `compileSdk` and `targetSdk` 37 (the latest stable API when M2b pinned them, in `libs.versions.toml`) | Both phones run Android 16; no legacy paths |
| Local DB | Room with KSP, exported schema | Full offline copy; migrations with tests | | Local DB | Room 3 (`androidx.room3`, 3.0.3 at M2b), KSP only, an `SQLiteDriver` with `androidx.sqlite:sqlite-bundled`, exported schema (D-012) | Full offline copy; migrations with tests |
| DI | Hilt | Standard, works with WorkManager and ViewModels | | DI | Hilt | Standard, works with WorkManager and ViewModels |
| HTTP | Ktor client (OkHttp engine), kotlinx.serialization JSON | Kotlin-first, multiplatform-style DTOs | | HTTP | Ktor client (OkHttp engine), kotlinx.serialization JSON | Kotlin-first, multiplatform-style DTOs |
| Background | WorkManager (periodic 15 min sync, expedited sync) | Survives process death, respects Doze | | Background | WorkManager (periodic 15 min sync, expedited sync) | Survives process death, respects Doze |
@@ -402,8 +402,8 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2).
| Charts | Vico (Compose). If Vico's current release cannot draw a stacked bar chart with Material 3 theming, hand-roll the three charts with Compose `Canvas` in `:core:design` | Mature Compose chart library; fallback is small | | Charts | Vico (Compose). If Vico's current release cannot draw a stacked bar chart with Material 3 theming, hand-roll the three charts with Compose `Canvas` in `:core:design` | Mature Compose chart library; fallback is small |
| Push | Firebase Cloud Messaging, only inside `:adapter:fcm` | Free tier; vendor isolated per `CLAUDE.md` | | Push | Firebase Cloud Messaging, only inside `:adapter:fcm` | Free tier; vendor isolated per `CLAUDE.md` |
| Secrets | Android Keystore: an AES-256-GCM key (non-exportable) encrypts the device token; ciphertext in a private DataStore file | `EncryptedSharedPreferences` is deprecated | | Secrets | Android Keystore: an AES-256-GCM key (non-exportable) encrypts the device token; ciphertext in a private DataStore file | `EncryptedSharedPreferences` is deprecated |
| Build | Gradle with version catalog `android/gradle/libs.versions.toml`, Gradle daemon on JDK 25 (pinned), bytecode target Java 17 | Existing pin | | Build | Gradle with version catalog `android/gradle/libs.versions.toml`, Gradle daemon on JDK 25 (pinned), bytecode target Java 17. With AGP 9, `check` runs only the debug unit-test variant. `versionCode` and `versionName` come from the Gradle properties `kvitt.versionCode` and `kvitt.versionName`, defaulting to `1` and `dev` (§10.6) | Existing pin |
| Tests | JUnit, Compose screenshot tests (Roborazzi or the Android Gradle Plugin's Compose Preview Screenshot Testing, whichever is stable at implementation time; M2b chooses and reports it, the orchestrator records it as D-010), Room migration tests, Ktor `MockEngine` | No mocking libraries (`CLAUDE.md`) | | Tests | JUnit, Compose screenshot tests with Roborazzi (1.76.0 at M2b) on Robolectric (4.17) with `@Config(sdk = [36])` (D-010), Room migration tests, Ktor `MockEngine`. Unit tests run on JDK 25 with `--add-exports java.base/jdk.internal.access` and `--add-opens java.base/java.io` for Robolectric, set in `build-logic` for every Android module (D-013). `failOnNoDiscoveredTests` is off only for modules without `src/test` (the shells) and on everywhere else (D-014) | No mocking libraries (`CLAUDE.md`) |
| Typeface | The Material 3 default typeface; the app bundles no font file (D-020) | No licence step, no font resource | | Typeface | The Material 3 default typeface; the app bundles no font file (D-020) | No licence step, no font resource |
Module graph (directory `android/`): Module graph (directory `android/`):
@@ -425,9 +425,11 @@ Module graph (directory `android/`):
| `:feature:settings` | Android library | Settings hub, profile, categories, recurring templates, devices, invites, about | `:core:model`, `:core:design` | | `:feature:settings` | Android library | Settings hub, profile, categories, recurring templates, devices, invites, about | `:core:model`, `:core:design` |
| `:app` | Android application | `MainActivity`, navigation graph, Hilt wiring, deep links, notification channel and building, `Application` class | everything | | `:app` | Android application | `MainActivity`, navigation graph, Hilt wiring, deep links, notification channel and building, `Application` class | everything |
**Screenshot tests (D-010).** `roborazzi.test.verify=true` in `android/gradle.properties` makes every unit-test run, and so `check`, compare renders with the committed goldens. An intended change is re-recorded with `tools/gradle-serial.sh recordRoborazziDebug`, and every changed PNG is opened before committing. AGP's Compose Preview Screenshot Testing plugin was still alpha when M2b chose.
Features never call `:domain` directly: repositories return computed state (balances, previews, proposals), implemented in `:data` with `:domain`. The architecture check (Gradle task in `check`, owned by milestone M2b) fails the build on any other dependency edge and on `java.time`, `java.io` or `android.*` imports in `:core:model` and `:domain`. Features never call `:domain` directly: repositories return computed state (balances, previews, proposals), implemented in `:data` with `:domain`. The architecture check (Gradle task in `check`, owned by milestone M2b) fails the build on any other dependency edge and on `java.time`, `java.io` or `android.*` imports in `:core:model` and `:domain`.
**Feature convention (D-017).** Binding for every feature module: **Feature convention (D-017).** Binding for every feature module (worked example and the stateless overload used by previews in `android/README.md`):
* Each feature exposes one composable entry point per screen. The entry point takes a ViewModel provided with `hiltViewModel()` and its navigation as plain lambdas (for example `onOpenExpense: (ExpenseId) -> Unit`, `onBack: () -> Unit`). * Each feature exposes one composable entry point per screen. The entry point takes a ViewModel provided with `hiltViewModel()` and its navigation as plain lambdas (for example `onOpenExpense: (ExpenseId) -> Unit`, `onBack: () -> Unit`).
* Features never reference other features or the `NavHost`. `:app` owns the `NavHost` and connects the lambdas to routes. * Features never reference other features or the `NavHost`. `:app` owns the `NavHost` and connects the lambdas to routes.
@@ -441,13 +443,13 @@ Features never call `:domain` directly: repositories return computed state (bala
| Concern | Choice | Why | | Concern | Choice | Why |
|---|---|---| |---|---|---|
| Language | Rust stable, edition 2024 | Owner's requirement; small RSS on a 1 GB Pi | | Language | Rust stable, edition 2024; the exact release pinned in `server/rust-toolchain.toml` (1.99.1 at M2a) and the same tag in the Dockerfile (§10.3) | Owner's requirement; small RSS on a 1 GB Pi |
| HTTP | axum on tokio (multi-thread runtime, 2 worker threads) | Mature, small | | HTTP | axum on tokio (multi-thread runtime, 2 worker threads) | Mature, small |
| DB | SQLite via sqlx, WAL mode, migrations embedded with `sqlx::migrate!` | One file, easy backup, ample for two users | | DB | SQLite via sqlx, WAL mode, migrations embedded with `sqlx::migrate!` | One file, easy backup, ample for two users |
| Serialization | serde, serde_json | Standard | | Serialization | serde, serde_json | Standard |
| OpenAPI | utoipa generates the document from code; `contracts/openapi.yaml` is the committed contract; a test asserts the generated document equals it (§14.3) | One contract, checked by the gate | | OpenAPI | utoipa generates the document from code; `contracts/openapi.yaml` is the committed contract; a test asserts the generated document equals it (§14.3) | One contract, checked by the gate |
| Logging | tracing, tracing-subscriber, JSON lines to stdout | Docker log rotation; no personal content (§8.7) | | Logging | tracing, tracing-subscriber, JSON lines to stdout | Docker log rotation; no personal content (§8.7) |
| Time | chrono with chrono-tz for Europe/Stockholm | DST-correct scheduling | | Time | chrono with chrono-tz for Europe/Stockholm (M2a confirmed; no other time crate) | DST-correct scheduling |
| Ids | uuid crate, v7 | Same format as phones | | Ids | uuid crate, v7 | Same format as phones |
| Images | image crate (decode JPEG/PNG/WebP, encode JPEG) | Pure Rust, no system libs | | Images | image crate (decode JPEG/PNG/WebP, encode JPEG) | Pure Rust, no system libs |
| HTTP client (FCM) | reqwest with rustls | No OpenSSL on the image | | HTTP client (FCM) | reqwest with rustls | No OpenSSL on the image |
@@ -458,15 +460,17 @@ Cargo workspace (directory `server/`):
| Crate | Kind | Contents | | Crate | Kind | Contents |
|---|---|---| |---|---|---|
| `kvitt-domain` | library, no I/O | Money types, split, balance, allocation, settlement validation (§14.5), `next_occurrence_date` and the day clamping of §9.5 (D-015), test-vector tests. Depends only on `serde`, `serde_json` (tests) and `uuid` | | `kvitt-domain` | library, no I/O | Money types, split, balance, allocation, settlement validation (§14.5), `next_occurrence_date` and the day clamping of §9.5 (D-015), test-vector tests. Depends only on `serde`; `serde_json` is a dev-dependency of the vector tests. Ids are plain strings (§14.5.1), so no `uuid` |
| `kvitt-server` | binary `kvitt-server` | Config, DB, migrations, HTTP API, auth, sync, audit, push, scheduler, pictures, APK, CLI subcommands (§10.4) | | `kvitt-server` | binary `kvitt-server` | Config, DB, migrations, HTTP API, auth, sync, audit, push, scheduler, pictures, APK, CLI subcommands (§10.4) |
| `kvitt-import` | binary `kvitt-import` | Splitwise CSV importer (§10.8). Depends on `kvitt-domain` and reuses `kvitt-server` as a library for DB access and audit writing (`kvitt-server` exposes a `lib.rs`) | | `kvitt-import` | binary `kvitt-import` | Splitwise CSV importer (§10.8). Depends on `kvitt-domain` and reuses `kvitt-server` as a library for DB access and audit writing (`kvitt-server` exposes a `lib.rs`) |
**Server seams (D-015).** So that M3, M4 and M9 work in parallel on M2a's skeleton: **Server seams (D-015).** So that M3, M4 and M9 work in parallel on M2a's skeleton:
* **Middleware in `router.rs` (M2a):** the `X-Kvitt-Protocol` check (426 `protocol_unsupported`), the `X-Kvitt-Server-Version` response header and the §8.6 body limits (1 MiB, 5 MiB for `PUT /me/picture`) are layers in `src/router.rs`. M3's rate limit (§8.6, AC-2.12) is a layer defined in `src/api/rate_limit.rs` and attached at one line in `router.rs` that M2a marks with a comment; that line is the only one M3 edits in `router.rs`. * **Middleware in `router.rs` (M2a):** the `X-Kvitt-Protocol` check (426 `protocol_unsupported`), the `X-Kvitt-Server-Version` response header and the §8.6 body limits (1 MiB, 5 MiB for `PUT /me/picture`) are layers in `src/router.rs`. On authenticated routes the protocol check runs before the token check, so a request with a bad protocol header and a bad token gets 426, not 401. M3's rate limit (§8.6, AC-2.12) is a layer defined in `src/api/rate_limit.rs` and attached at one line in `router.rs` that M2a marks with a comment; that line is the only one M3 edits in `router.rs`.
* **Background tasks:** M2a creates `src/tasks/mod.rs` with a `start` function that `serve` calls once after migrations (initially doing nothing). M4 owns `src/tasks/**` and starts the scheduler, the push sender and the `sync_mutations` pruning from it. * **Background tasks:** M2a creates `src/tasks/mod.rs` with a `start` function that `serve` calls once after migrations (initially doing nothing). M4 owns `src/tasks/**` and starts the scheduler, the push sender and the `sync_mutations` pruning from it.
* **Audit and push interfaces:** M2a defines the traits `AuditWriter` (in `src/audit/`) and `PushQueue` (in `src/push/`) with no-op implementations, and passes them through the application state. M4 replaces the implementations (audit writer and diff of §14.4.7, FCM queue of §14.6); M3 and M9 only call the traits. * **Audit and push interfaces:** M2a defines the traits `AuditWriter` (in `src/audit/`) and `PushQueue` (in `src/push/`) with no-op implementations, and passes them through the application state (`AppState.audit`, `AppState.push`). M4 replaces the implementations (audit writer and diff of §14.4.7, FCM queue of §14.6); M3 and M9 only call the traits. As M2a built them (exact signatures in `server/README.md`):
* `AuditWriter::write` takes the caller's open connection inside its `BEGIN IMMEDIATE` transaction and one `AuditEvent` (entity type and id, action, source, actor user and device, `client_at`, `server_at`, and the wire object before the change, absent for a create, and after it, as JSON), and returns the new audit entry's id or an `AppError`. The writer derives `changes` (§14.4.7) and takes the entry's `server_version` from that same transaction.
* `PushQueue::enqueue` takes one `PushMessage` (push type, one of the eight of §14.6; entity id or none; actor user id; count). It never blocks and never fails; callers call it after commit.
* **Recurring generation:** M2a implements `next_occurrence_date` and the day clamping in `kvitt-domain` (§14.5.2). M3 writes the one function that generates the due periods of one template (§9.5: candidate periods, one transaction per candidate, audit through `AuditWriter`, push through `PushQueue`) in `src/api/recurring.rs`, used by E-28 and E-29; M4's scheduler calls that function for every template. M4 therefore lands after M3 (it may start in parallel and rebases on M3 before its gate). * **Recurring generation:** M2a implements `next_occurrence_date` and the day clamping in `kvitt-domain` (§14.5.2). M3 writes the one function that generates the due periods of one template (§9.5: candidate periods, one transaction per candidate, audit through `AuditWriter`, push through `PushQueue`) in `src/api/recurring.rs`, used by E-28 and E-29; M4's scheduler calls that function for every template. M4 therefore lands after M3 (it may start in parallel and rebases on M3 before its gate).
### 6.4 Why these choices ### 6.4 Why these choices
@@ -539,7 +543,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in
* The server stores only `token_hash` = lowercase hex SHA-256 of the full token string. * The server stores only `token_hash` = lowercase hex SHA-256 of the full token string.
* Tokens do not expire. They stop working only on revocation. * Tokens do not expire. They stop working only on revocation.
* Every authenticated request sends `Authorization: Bearer <token>`. The server hashes it, looks up a device with that hash and `revoked_at IS NULL`, and loads the user. Missing or unknown token: 401 `unauthorized`. Revoked: 401 `device_revoked`. * Every authenticated request sends `Authorization: Bearer <token>`. The server hashes it, looks up a device with that hash and `revoked_at IS NULL`, and loads the user. Missing or unknown token: 401 `unauthorized`. Revoked: 401 `device_revoked`.
* The server updates `devices.last_seen_at` and `devices.app_version_code` at most once per hour per device. * The server updates `devices.last_seen_at` and `devices.app_version_code` at most once per hour per device. That update sets `app_version_code` only when the request carries `X-Kvitt-App-Version` and its value parses as an integer; otherwise the stored value is kept.
* On the phone the token is encrypted with an Android Keystore AES-256-GCM key (alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, no export) and the ciphertext and IV are stored in DataStore file `auth.preferences_pb`. The server base URL, user id and device id are stored next to it in plain text. * On the phone the token is encrypted with an Android Keystore AES-256-GCM key (alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, no export) and the ciphertext and IV are stored in DataStore file `auth.preferences_pb`. The server base URL, user id and device id are stored next to it in plain text.
* The token is never logged, never put in a URL, never included in a crash report or screenshot. * The token is never logged, never put in a URL, never included in a crash report or screenshot.
@@ -574,7 +578,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in
* Unauthenticated endpoints: at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds. * Unauthenticated endpoints: at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds.
* Authenticated endpoints: at most 600 requests per minute per device. * Authenticated endpoints: at most 600 requests per minute per device.
* Invite accept failures: AC-2.12. * Invite accept failures: AC-2.12.
* Request bodies: 1 MiB maximum, except `PUT /me/picture` at 5 MiB. Larger: 413 `payload_too_large`. * Request bodies: 1 MiB maximum, except `PUT /me/picture` at 5 MiB. Larger: 413 `payload_too_large`. Both limits are layers in `router.rs` (§6.3).
* JSON strings are validated for length after trimming leading and trailing whitespace; control characters other than newline in `notes` are refused with `validation_failed`. * JSON strings are validated for length after trimming leading and trailing whitespace; control characters other than newline in `notes` are refused with `validation_failed`.
* The rate limiter is in memory (resets on restart); no table. * The rate limiter is in memory (resets on restart); no table.
@@ -724,6 +728,8 @@ All string lengths are counted in Unicode code points after trimming. One table,
| `GET /sync` `limit` | 1 to 1000, default 500 | `invalid_request` | | `GET /sync` `limit` | 1 to 1000, default 500 | `invalid_request` |
| picture upload | ≤ 5 MiB, ≤ 8000 × 8000 px, JPEG/PNG/WebP | `payload_too_large`, `image_invalid`, `unsupported_media_type` | | picture upload | ≤ 5 MiB, ≤ 8000 × 8000 px, JPEG/PNG/WebP | `payload_too_large`, `image_invalid`, `unsupported_media_type` |
On the phone, a rule of this table that has no domain error token of §14.5.3 (the length limits, the date limits, `day_of_month`, the periods) is reported as `FieldError.error` = `validation_failed` (§14.2.4), the same token as the REST code; a missing required value is `required`.
The phone input field for amounts accepts digits, one decimal separator (`,` or `.`) and at most two decimals, without sign, and converts to öre exactly (string parsing, never floating point). The phone input field for amounts accepts digits, one decimal separator (`,` or `.`) and at most two decimals, without sign, and converts to öre exactly (string parsing, never floating point).
### 9.7 Icon and colour tokens ### 9.7 Icon and colour tokens
@@ -828,7 +834,7 @@ File `infra/compose.yaml`, deployed to `/opt/kvitt/compose.yaml` on the Pi with
### 10.3 Images, registry and build ### 10.3 Images, registry and build
* Script `infra/build-images.sh [tag]` on the Mac: `docker buildx build --platform linux/arm64 --push` for the three Kvitt images, tag defaults to the short git SHA of `HEAD`, also tags `latest`. Refuses to run with a dirty working tree. * Script `infra/build-images.sh [tag]` on the Mac: `docker buildx build --platform linux/arm64 --push` for the three Kvitt images, tag defaults to the short git SHA of `HEAD`, also tags `latest`. Refuses to run with a dirty working tree.
* `server` image: multi-stage. Stage 1 `rust:<version>-bookworm`, where `<version>` is the exact Rust release pinned in `server/rust-toolchain.toml` (the current stable when M2a pins it; the two always change together), builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. * `server` image: multi-stage. Stage 1 `rust:<version>-bookworm`, where `<version>` is the exact Rust release pinned in `server/rust-toolchain.toml` (the current stable when M2a pins it; the two always change together), builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) pinned by digest, with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. The Dockerfile selects the Rust target from `TARGETARCH`: `arm64` (`aarch64-unknown-linux-gnu`) is the shipped target; `amd64` (`x86_64-unknown-linux-gnu`) also builds, emulated on the Mac, for local use only.
* `cert-sync` image: `alpine` with `openssh-client`, `curl` and a shell script `infra/cert-sync/sync.sh` run by `crond`. * `cert-sync` image: `alpine` with `openssh-client`, `curl` and a shell script `infra/cert-sync/sync.sh` run by `crond`.
* `backup` image: `alpine` with `sqlite`, `gzip`, `tar`, `rclone` and `infra/backup/backup.sh` run by `crond`. * `backup` image: `alpine` with `sqlite`, `gzip`, `tar`, `rclone` and `infra/backup/backup.sh` run by `crond`.
* Script `infra/deploy.sh <tag>` on the Mac: copies `compose.yaml` and `Caddyfile` to the Pi over SSH, sets `KVITT_TAG` in `/opt/kvitt/.env`, runs `docker compose pull && docker compose up -d`, waits for `server` to be healthy, prints `GET /health`. * Script `infra/deploy.sh <tag>` on the Mac: copies `compose.yaml` and `Caddyfile` to the Pi over SSH, sets `KVITT_TAG` in `/opt/kvitt/.env`, runs `docker compose pull && docker compose up -d`, waits for `server` to be healthy, prints `GET /health`.
@@ -865,10 +871,10 @@ Files under `KVITT_DATA_DIR`:
| `bootstrap` | FR-1 | 0 ok, 2 already initialized | | `bootstrap` | FR-1 | 0 ok, 2 already initialized |
| `pair-device --user <user_id>` | AC-1.6 | 0 ok, 3 unknown user | | `pair-device --user <user_id>` | AC-1.6 | 0 ok, 3 unknown user |
| `rotate-epoch` | Sets a new random `sync_epoch` (§10.9) | 0 | | `rotate-epoch` | Sets a new random `sync_epoch` (§10.9) | 0 |
| `healthcheck` | Calls `http://127.0.0.1:8080/health`, exit 0 if 200; used by the compose healthchecks of both stacks (no curl in the image) | 0 or 1 | | `healthcheck` | Calls `http://127.0.0.1:<port of KVITT_BIND>/health` (8080 by default), exit 0 if 200; used by the compose healthchecks of both stacks (no curl in the image) | 0 or 1 |
| `migrate` | Run migrations only | 0 | | `migrate` | Run migrations only | 0 |
Run them on the Pi with `docker compose exec server kvitt-server <command>` (or `docker compose run --rm server <command>` when `server` is stopped). Run them on the Pi with `docker compose exec server kvitt-server <command>` (or `docker compose run --rm server <command>` when `server` is stopped). Outside Docker, from the workspace root `server/`: `cargo run -p kvitt-server -- <command>` (the `-p` is needed because the workspace has two binaries), with `KVITT_DATA_DIR` set (`server/README.md`).
### 10.5 TLS ### 10.5 TLS
@@ -1068,12 +1074,12 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| U2 | UX design spec | §13.4 phase 2 files in `docs/ux/` | Every string key has `en` and `sv`; every screen has every state that `design-spec.md` lists for it (§13.3) | Approve the design spec | U1 and the owner's pick | | U2 | UX design spec | §13.4 phase 2 files in `docs/ux/` | Every string key has `en` and `sv`; every screen has every state that `design-spec.md` lists for it (§13.3) | Approve the design spec | U1 and the owner's pick |
| M1 | Contracts and vectors | `contracts/openapi.yaml`, `contracts/schema/server-v1.sql`, `contracts/test-vectors/*.json`, `contracts/examples/**/*.json`, `tools/contracts-check.py` | `python3 tools/contracts-check.py` passes: all JSON parses, every vector file matches §14.5.5, a stdlib-only Python reference of §9.1, §9.3, §9.4 reproduces every expected value, every example validates against the shapes of §14.3 by key set | none | M0 | | M1 | Contracts and vectors | `contracts/openapi.yaml`, `contracts/schema/server-v1.sql`, `contracts/test-vectors/*.json`, `contracts/examples/**/*.json`, `tools/contracts-check.py` | `python3 tools/contracts-check.py` passes: all JSON parses, every vector file matches §14.5.5, a stdlib-only Python reference of §9.1, §9.3, §9.4 reproduces every expected value, every example validates against the shapes of §14.3 by key set | none | M0 |
| M2a | Server skeleton and `kvitt-domain` | Cargo workspace, three crates, config, DB pool, migration `0001_init.sql` byte-identical to `contracts/schema/server-v1.sql`, error type with every §14.3.2 code, auth extractor (§8.2), all routes of §7 registered with handlers returning 501 `not_implemented`, utoipa document equal to `contracts/openapi.yaml`, `GET /health` and `GET /setup/status` implemented, `kvitt-domain` passing all vectors plus `next_occurrence_date`, `server/Dockerfile`; shell files in M3, M4 and M9 paths (D-004); the seams of §6.3 (D-015) | Server gate (includes the OpenAPI match test, the migration identity test, the vector tests) | none | M1 | | M2a | Server skeleton and `kvitt-domain` | Cargo workspace, three crates, config, DB pool, migration `0001_init.sql` byte-identical to `contracts/schema/server-v1.sql`, error type with every §14.3.2 code, auth extractor (§8.2), all routes of §7 registered with handlers returning 501 `not_implemented`, utoipa document equal to `contracts/openapi.yaml`, `GET /health` and `GET /setup/status` implemented, `kvitt-domain` passing all vectors plus `next_occurrence_date`, `server/Dockerfile`; shell files in M3, M4 and M9 paths (D-004); the seams of §6.3 (D-015) | Server gate (includes the OpenAPI match test, the migration identity test, the vector tests) | none | M1 |
| M2b | Android skeleton and `:domain` | Gradle project in `android/` with every module of §6.2, version catalog, architecture check, `:core:model` types and repository interfaces of §14.2.4, `:domain` passing all vectors (including analytics), `:core:fakes` with the empty scenario (§6.2, D-018) for each repository, module shells for every module with one empty entry composable per feature (D-004), the feature convention of §6.2 in `build-logic` (D-017), `:app` launching to an empty themed screen, `:app` string files with the app name only in both languages, lint `MissingTranslation` as an error in every module | Android gate | Plug the phone in once to confirm `installDebug` works | M1 | | M2b | Android skeleton and `:domain` | Gradle project in `android/` with every module of §6.2, version catalog, architecture check, `:core:model` types and repository interfaces of §14.2.4, `:domain` passing all vectors (including analytics), `:core:fakes` with the empty scenario (§6.2, D-018) for each repository, the Room 3 entities of §14.2.1 and §14.2.2 and `KvittDatabase` version 1 in `:data` with the exported schema (D-012), screenshot tests with Roborazzi (D-010), module shells for every module with one empty entry composable per feature (D-004), the feature convention of §6.2 in `build-logic` (D-017), `:app` launching to an empty themed screen, `:app` string files with the app name only in both languages, lint `MissingTranslation` as an error in every module | Android gate | Plug the phone in once to confirm `installDebug` works | M1 |
| M2c-dev | Infra for development (D-001, D-022) | Landed `c885aba`. `infra/dev/compose.yaml` (`server` built from `server/Dockerfile`, no Caddy, no TLS, bind-mounted data, `infra/secrets/` mounted read-only at `/secrets`, `backup` runnable on demand), `infra/dev/.env.example`, the dev scripts in `infra/dev/` (build, up, logs, backup, `copy-google-services.sh`), `infra/README.md` (dev stack and Firebase), `infra/backup/{Dockerfile, backup.sh}` (Drive upload skipped when `RCLONE_FOLDER_ID` is empty), Firebase project and apps through the REST API (§10.7), service-account key and `google-services.json` in `infra/secrets/` | `docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Mac (with the M2a image): `GET /health` answers 200 from the Mac; one local backup run produces a snapshot that passes `PRAGMA integrity_check` | `gcloud auth login`; on the phone on the home Wi-Fi, open the dev server's `/health` and answer yes/no that it shows `"status":"ok"` | M0 (uses the M2a image for the health check when available) | | M2c-dev | Infra for development (D-001, D-022) | Landed `c885aba`. `infra/dev/compose.yaml` (`server` built from `server/Dockerfile`, no Caddy, no TLS, bind-mounted data, `infra/secrets/` mounted read-only at `/secrets`, `backup` runnable on demand), `infra/dev/.env.example`, the dev scripts in `infra/dev/` (build, up, logs, backup, `copy-google-services.sh`), `infra/README.md` (dev stack and Firebase), `infra/backup/{Dockerfile, backup.sh}` (Drive upload skipped when `RCLONE_FOLDER_ID` is empty), Firebase project and apps through the REST API (§10.7), service-account key and `google-services.json` in `infra/secrets/` | `docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Mac (with the M2a image): `GET /health` answers 200 from the Mac; one local backup run produces a snapshot that passes `PRAGMA integrity_check` | `gcloud auth login`; on the phone on the home Wi-Fi, open the dev server's `/health` and answer yes/no that it shows `"status":"ok"` | M0 (uses the M2a image for the health check when available) |
| M2c-pi | Infra on the Pi (D-001) | `infra/` Pi compose stack, Caddyfile, cert-sync image, build, deploy and publish scripts, `infra/README.md` (Pi provisioning, router forwarding, trust of the `gitea.lan` registry), `infra/RESTORE.md`, Drive backups to folder id `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`, Gitea Actions image build from the owner's example workflow in `.gitea/workflows/`, the `/opt/kvitt/import` bind mount (§10.2) | `docker compose -f infra/compose.yaml config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Pi: cert-sync fetched a valid certificate, `https://icehack.asuscomm.com:8443/health` answers from the phone on mobile data; one backup file appears in the Drive folder | Answer OQ-3; grant at the start of the sprint the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive (D-023); set up key-based SSH from the Mac to the Kvitt Pi; provide the example Gitea Actions workflow and the Gitea credentials; install Docker on the Pi if missing; add `gitea.lan` to `insecure-registries` on the Pi; set the router forward 8443 → Kvitt Pi; add the cert-sync public key to the Home Assistant SSH add-on; `rclone authorize "drive"` | M2c-dev, M8 | | M2c-pi | Infra on the Pi (D-001) | `infra/` Pi compose stack, Caddyfile, cert-sync image, build, deploy and publish scripts, `infra/README.md` (Pi provisioning, router forwarding, trust of the `gitea.lan` registry), `infra/RESTORE.md`, Drive backups to folder id `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`, Gitea Actions image build from the owner's example workflow in `.gitea/workflows/`, the `/opt/kvitt/import` bind mount (§10.2) | `docker compose -f infra/compose.yaml config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Pi: cert-sync fetched a valid certificate, `https://icehack.asuscomm.com:8443/health` answers from the phone on mobile data; one backup file appears in the Drive folder | Answer OQ-3; grant at the start of the sprint the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive (D-023); set up key-based SSH from the Mac to the Kvitt Pi; provide the example Gitea Actions workflow and the Gitea credentials; install Docker on the Pi if missing; add `gitea.lan` to `insecure-registries` on the Pi; set the router forward 8443 → Kvitt Pi; add the cert-sync public key to the Home Assistant SSH add-on; `rclone authorize "drive"` | M2c-dev, M8 |
| M3 | Server core API | E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (`src/api/rate_limit.rs`), `bootstrap`, `pair-device`, the recurring generation function of §6.3 (D-015) | 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`) | none | M2a | | M3 | Server core API | E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (`src/api/rate_limit.rs`), `bootstrap`, `pair-device`, the recurring generation function of §6.3 (D-015) | 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`) | none | M2a |
| M4 | Server sync, audit, push, scheduler | E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) | | M4 | Server sync, audit, push, scheduler | E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) |
| M5 | Android data layer | Room DB v1 (§14.2) with exported schema, DAOs, Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b | | M5 | Android data layer | DAOs on M2b's Room 3 entities and `KvittDatabase` v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b |
| M6 | Design system | `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts, formatters (§9.8), shared strings (including the notification copy) per `docs/ux/design-spec.md` and `docs/ux/strings.csv`, screenshot tests of every component in light and dark, Swedish and English | Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b, the §9.7 colour change | | M6 | Design system | `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts, formatters (§9.8), shared strings (including the notification copy) per `docs/ux/design-spec.md` and `docs/ux/strings.csv`, screenshot tests of every component in light and dark, Swedish and English | Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b, the §9.7 colour change |
| M7a | Feature: onboarding | `:feature:onboarding` screens S-1, S-2, S-3, S-18 | Android gate with screenshot tests of every state, sv and en, light and dark | none | M6 | | M7a | Feature: onboarding | `:feature:onboarding` screens S-1, S-2, S-3, S-18 | Android gate with screenshot tests of every state, sv and en, light and dark | none | M6 |
| M7b | Feature: home and account | `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6 | | M7b | Feature: home and account | `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6 |
@@ -1088,9 +1094,10 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
### 11.3 Notes per milestone ### 11.3 Notes per milestone
* **M1** writes the OpenAPI file by hand from §14.3 (no code exists yet). M2a then annotates handlers so the generated document matches it; the equality test compares parsed values, not text (D-016). M2a may change `contracts/openapi.yaml` only representationally (key order, `$ref` naming, description wording), listed in the plan report, with no spec bump (§14.8). Any semantic change follows the contract-change rule (§14.8). * **M1** writes the OpenAPI file by hand from §14.3 (no code exists yet). M2a then annotates handlers so the generated document matches it; the equality test compares parsed values, not text (D-016), after the normalisations of §14.8 (D-005). M2a may change `contracts/openapi.yaml` only representationally (key order, `$ref` naming, description wording), listed in the plan report, with no spec bump (§14.8). Any semantic change follows the contract-change rule (§14.8).
* **M2a** owns the router and registers every route up front so that M3 and M4 only fill in handler bodies in their own files. The same for the utoipa `ApiDoc`. M2a creates the shell files of the later server milestones (D-004, §14.7) and the seams of §6.3 (D-015). `server/rust-toolchain.toml` pins the exact current stable Rust release and the Dockerfile uses the same version (§10.3). * **M2a** owns the router and registers every route up front so that M3 and M4 only fill in handler bodies in their own files. The same for the utoipa `ApiDoc`. M2a creates the shell files of the later server milestones (D-004, §14.7) and the seams of §6.3 (D-015). `server/rust-toolchain.toml` pins the exact current stable Rust release and the Dockerfile uses the same version (§10.3).
* **M2b** writes the `*State` classes and repository interfaces of §14.2.4 so that M5 and M7 work against the same interfaces in parallel. It creates a module shell for every module of §6.2, with one empty entry composable per feature module (D-004), puts the feature convention of §6.2 (D-017) into the feature convention plugin, and fixes how `resolveRange` signals an invalid `CUSTOM` range (§14.5.4, D-018). Its screenshot-test choice becomes D-010. * **M2b** writes the `*State` classes and repository interfaces of §14.2.4 so that M5 and M7 work against the same interfaces in parallel. It creates a module shell for every module of §6.2, with one empty entry composable per feature module (D-004), puts the feature convention of §6.2 (D-017) into the feature convention plugin, and fixes how `resolveRange` signals an invalid `CUSTOM` range (§14.5.4, D-018: `IncompleteCustomRangeException`, D-011). Its screenshot-test choice is D-010 (Roborazzi). It also created the Room 3 entities and `KvittDatabase` v1 in `:data` (D-012), so M5 builds on them.
* **M5** owns `:data` and builds on the entities, `KvittDatabase` v1 and the exported schema `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` that M2b created (D-012); it adds DAOs, the API client, sync and the repositories. Room 3 migrations take an `SQLiteConnection` (§14.2.3).
* **M7x** features use `:core:fakes` only; they never depend on `:data`. Each feature agent adds its fake scenarios in `android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/` and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from `:core:design` (D-019). * **M7x** features use `:core:fakes` only; they never depend on `:data`. Each feature agent adds its fake scenarios in `android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/` and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from `:core:design` (D-019).
* **M2c-dev / M2c-pi** (D-001): M2c-dev builds only what development needs on the Mac and runs now; M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) and runs after M8, before M10. * **M2c-dev / M2c-pi** (D-001): M2c-dev builds only what development needs on the Mac and runs now; M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) and runs after M8, before M10.
* **M8** is the only plan that edits `:app` after M2b. It owns the `:app` items network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), and copies `google-services.json` from `infra/secrets/` with `infra/dev/copy-google-services.sh` (§10.7). * **M8** is the only plan that edits `:app` after M2b. It owns the `:app` items network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), and copies `google-services.json` from `infra/secrets/` with `infra/dev/copy-google-services.sh` (§10.7).
@@ -1469,7 +1476,7 @@ Server-side rules on top of the DDL:
### 14.2 Room entities (phone) ### 14.2 Room entities (phone)
Database class `KvittDatabase` in `:data`, file name `kvitt.db`, version **1**, `exportSchema = true` with schemas in `android/data/schemas/` (committed; the exported JSON for version 1 is a contract file once M5 lands). Package `se.hagstedt.kvitt.data.db`. Database class `KvittDatabase` in `:data`, file name `kvitt.db`, version **1**, `exportSchema = true` with schemas in `android/data/schemas/` (committed). Package `se.hagstedt.kvitt.data.db`. Room 3 (`androidx.room3`, KSP only) on an `SQLiteDriver` with `sqlite-bundled` (D-012). M2b created the entities and `KvittDatabase` version 1; the exported `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` is a contract file (§14.8).
Rules: Rules:
@@ -1512,7 +1519,7 @@ Per-phone preferences (last used account, last used category per account, "insta
#### 14.2.3 Migrations #### 14.2.3 Migrations
Version 1 has no migrations. Every later Room version adds a `Migration` and a migration test in `:data` (`CLAUDE.md`), and is a contract change (§14.8). Version 1 has no migrations. Every later Room version adds a `Migration` and a migration test in `:data` (`CLAUDE.md`), and is a contract change (§14.8). Room 3 migrations operate on an `SQLiteConnection` (the driver API), not on a `SupportSQLiteDatabase` (D-012).
#### 14.2.4 UI contract: types and repository interfaces in `:core:model` #### 14.2.4 UI contract: types and repository interfaces in `:core:model`
@@ -1561,7 +1568,7 @@ sealed interface SaveResult<out T> {
data class Saved<T>(val id: T) : SaveResult<T> data class Saved<T>(val id: T) : SaveResult<T>
data class Invalid(val errors: List<FieldError>) : SaveResult<Nothing> data class Invalid(val errors: List<FieldError>) : SaveResult<Nothing>
} }
data class FieldError(val field: String, val error: String) // error = a domain error token of §14.5.3 or "required" data class FieldError(val field: String, val error: String) // error = a domain error token of §14.5.3, "required", or "validation_failed" for a §9.6 rule without a domain token
// Session and onboarding (S-1, S-2, S-3, S-18) // Session and onboarding (S-1, S-2, S-3, S-18)
sealed interface SessionState { sealed interface SessionState {
@@ -1594,7 +1601,7 @@ sealed interface ExpenseEffect {
} }
sealed interface LedgerRow { sealed interface LedgerRow {
data class ExpenseRow(val id: ExpenseId, val date: String, val description: String, val category: Category, val amountOre: Long, val effect: ExpenseEffect, val pending: Boolean) : LedgerRow data class ExpenseRow(val id: ExpenseId, val date: String, val description: String, val category: Category, val amountOre: Long, val effect: ExpenseEffect, val pending: Boolean) : LedgerRow
data class SettlementRow(val id: SettlementId, val date: String, val from: User, val to: User, val amountOre: Long, val accountCount: Int, val pending: Boolean) : LedgerRow data class SettlementRow(val id: SettlementId, val date: String, val from: User, val to: User, val amountOre: Long, val accountCount: Int, val pending: Boolean) : LedgerRow // amountOre: in an account's list (S-5) the allocation to that account, elsewhere the total (AC-11.7); accountCount: allocations of the whole settlement
} }
data class LedgerMonth(val period: String, val householdSpendOre: Long, val rows: List<LedgerRow>) data class LedgerMonth(val period: String, val householdSpendOre: Long, val rows: List<LedgerRow>)
@@ -1641,8 +1648,8 @@ interface SettlementRepository {
data class AnalyticsFilter(val preset: RangePreset, val customStart: String?, val customEnd: String?, val granularity: Granularity, val measure: Measure, val groupBy: GroupBy) data class AnalyticsFilter(val preset: RangePreset, val customStart: String?, val customEnd: String?, val granularity: Granularity, val measure: Measure, val groupBy: GroupBy)
data class GroupKey(val id: String, val label: String?, val color: ColorToken?) // label null: UI uses the default category name data class GroupKey(val id: String, val label: String?, val color: ColorToken?) // label null: UI uses the default category name
data class SpendBucket(val key: String, val start: String, val end: String, val totals: Map<String, Long>, val totalOre: Long) data class SpendBucket(val key: String, val start: String, val end: String, val totals: Map<String, Long>, val totalOre: Long)
data class AnalyticsState(val filter: AnalyticsFilter, val start: String, val end: String, val groups: List<GroupKey>, val buckets: List<SpendBucket>, val byCategory: List<Pair<GroupKey, Long>>, val byAccount: List<Pair<GroupKey, Long>>, val totalOre: Long) data class AnalyticsState(val filter: AnalyticsFilter, val start: String, val end: String, val groups: List<GroupKey>, val buckets: List<SpendBucket>, val byCategory: List<Pair<GroupKey, Long>>, val byAccount: List<Pair<GroupKey, Long>>, val totalOre: Long) // no error field: a CUSTOM filter with a missing date is resolved with today for that date; the S-11 range editor never sends one
data class DrillDownState(val title: String, val totalOre: Long, val rows: List<LedgerRow.ExpenseRow>) data class DrillDownState(val title: String, val totalOre: Long, val rows: List<LedgerRow.ExpenseRow>) // title: unlocalised parts joined by ", " (category custom name or default key, account name, bucket key); S-12 formats its visible title itself from the drillDown arguments
interface AnalyticsRepository { interface AnalyticsRepository {
fun analytics(filter: AnalyticsFilter): Flow<AnalyticsState> fun analytics(filter: AnalyticsFilter): Flow<AnalyticsState>
fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow<DrillDownState> fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow<DrillDownState>
@@ -1708,6 +1715,14 @@ interface ConnectivityRepository { val online: Flow<Boolean> } // §9.9
Agents may add fields to these types only through a contract change (§14.8). A feature that needs a value not listed asks the orchestrator. Agents may add fields to these types only through a contract change (§14.8). A feature that needs a value not listed asks the orchestrator.
Readings fixed in v0.4 (no type changes):
* **`FieldError.error`.** Allowed values: a domain token of §14.5.3, `required` (a required value is missing or blank), or `validation_failed` (a §9.6 rule with no domain token: lengths, dates, `day_of_month`, periods).
* **Analytics range errors.** `AnalyticsState` has no error field. `AnalyticsRepository.analytics` never fails for a `CUSTOM` filter with `customStart` or `customEnd` null: it resolves the missing date as today. The S-11 custom-range editor does not let the user apply a range with a missing date, so this path is defensive only. `resolveRange` still throws `IncompleteCustomRangeException` (§14.5.4); the repository supplies today before calling it.
* **`DrillDownState.title`.** Stays a `String`. The repository fills it with the unlocalised parts as above, as the fakes do. S-12 does not display it as is: the feature formats the visible title ("Mat & hushåll, oktober 2026") from the arguments it passed to `drillDown` (the category or account, resolved through `GroupKey` with the default category name from resources, and the bucket key formatted with `DateFormatter`).
* **Settlement rows in an account's list** carry the allocation to that account (AC-11.7).
* **`Share` names.** `se.hagstedt.kvitt.model.Share` (a split share) and `se.hagstedt.kvitt.model.analytics.AnalyticsMeasure.Share` (the measure) share a simple name in different packages. Accepted; code that needs both imports one with an alias or qualifies it.
### 14.3 OpenAPI 3.1 outline ### 14.3 OpenAPI 3.1 outline
Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt`, `info.version` `1`, one server `https://icehack.asuscomm.com:8443`, security scheme `device` = HTTP bearer) from this outline. This outline is binding; the YAML file must not add or drop anything. Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt`, `info.version` `1`, one server `https://icehack.asuscomm.com:8443`, security scheme `device` = HTTP bearer) from this outline. This outline is binding; the YAML file must not add or drop anything.
@@ -1715,8 +1730,9 @@ Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt
#### 14.3.1 Common rules #### 14.3.1 Common rules
* Content type `application/json; charset=utf-8` for every request and response body, except: `PUT /me/picture` (request `image/jpeg`, `image/png` or `image/webp`, raw bytes), `GET /users/{user_id}/picture` (response `image/jpeg`), `GET /i/{code}` (response `text/html; charset=utf-8`), `GET /apk/files/{file_name}` (response `application/vnd.android.package-archive`). * Content type `application/json; charset=utf-8` for every request and response body, except: `PUT /me/picture` (request `image/jpeg`, `image/png` or `image/webp`, raw bytes), `GET /users/{user_id}/picture` (response `image/jpeg`), `GET /i/{code}` (response `text/html; charset=utf-8`), `GET /apk/files/{file_name}` (response `application/vnd.android.package-archive`).
* Request headers the app sends on every call: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version: <versionCode>`, `Accept-Language: <device locale>`, and `Authorization: Bearer <token>` on authenticated endpoints. A request to an authenticated endpoint without `X-Kvitt-Protocol` or with a value other than `1` gets 426 `protocol_unsupported` (the app then shows the update banner and stops syncing until updated). * Request headers the app sends on every call: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version: <versionCode>`, `Accept-Language: <device locale>`, and `Authorization: Bearer <token>` on authenticated endpoints. A request to an authenticated endpoint without `X-Kvitt-Protocol` or with a value other than `1` gets 426 `protocol_unsupported` (the app then shows the update banner and stops syncing until updated). The protocol check comes before the token check.
* Every response carries `X-Kvitt-Server-Version: <crate version>`. * Every response carries `X-Kvitt-Server-Version: <crate version>`.
* A known path with a method it does not support gets 405 with an empty body (no error code; the only error response without the JSON error shape), the `X-Kvitt-Server-Version` header included. An unknown path gets 404 `not_found` with the JSON error shape.
* Unknown JSON fields in requests are ignored. Missing required fields or wrong types: 400 `invalid_request`. Values outside §9.6: 422 `validation_failed` with `field`. The §9.6 value limits are not expressed as JSON Schema constraints in `contracts/openapi.yaml`; the schema fixes types only, and breaking a limit gives 422 `validation_failed`. * Unknown JSON fields in requests are ignored. Missing required fields or wrong types: 400 `invalid_request`. Values outside §9.6: 422 `validation_failed` with `field`. The §9.6 value limits are not expressed as JSON Schema constraints in `contracts/openapi.yaml`; the schema fixes types only, and breaking a limit gives 422 `validation_failed`.
* Optional fields in responses are present with `null`, never omitted. (One exception: accepted and rejected results in the `POST /sync` response have different key sets, as the §14.4.3 example shows.) * Optional fields in responses are present with `null`, never omitted. (One exception: accepted and rejected results in the `POST /sync` response have different key sets, as the §14.4.3 example shows.)
* `PATCH` bodies: every field optional; absent = unchanged; at least one field required (`invalid_request`). * `PATCH` bodies: every field optional; absent = unchanged; at least one field required (`invalid_request`).
@@ -1836,9 +1852,9 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default
```json ```json
{ "status": "ok", "server_version": "0.1.0", "schema_version": 1, "db_ok": true } { "status": "ok", "server_version": "0.1.0", "schema_version": 1, "db_ok": true }
``` ```
When the database check (`SELECT 1`) fails: 503 with the same shape, `status` `"degraded"`, `db_ok` false. The OpenAPI document declares the 503 body as `oneOf` the health object or Error (an `unavailable` error from a shutting-down server is also possible). `schema_version` comes from a constant in the server code (the version its embedded migrations produce), not from the database, so `/health` answers with the database down. When the database check (`SELECT 1`) fails: 503 with the same shape, `status` `"degraded"`, `db_ok` false. The OpenAPI document declares the 503 body as `oneOf` the health object or Error (an `unavailable` error from a shutting-down server is also possible).
**E-2 `GET /setup/status`** (none). 200 `{ "initialized": bool }`. **E-2 `GET /setup/status`** (none). 200 `{ "initialized": bool }`. `initialized` is true when a user with role `admin` exists (AC-1.5).
**E-3 `POST /invites/accept`** (none). Request: **E-3 `POST /invites/accept`** (none). Request:
```json ```json
@@ -2147,7 +2163,9 @@ Rust (`kvitt-domain`):
```rust ```rust
pub enum SplitSpec { Ratio { bp: BTreeMap<String, u32> }, Exact { ore: BTreeMap<String, i64> }, Full { bearer_user_id: String } } pub enum SplitSpec { Ratio { bp: BTreeMap<String, u32> }, Exact { ore: BTreeMap<String, i64> }, Full { bearer_user_id: String } }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")]
pub enum SplitError { AmountOutOfRange, UsersInvalid, PayerUnknown, BpOutOfRange, BpSumNot10000, ExactNegative, ExactSumMismatch, BearerUnknown, ShareUsersMismatch } pub enum SplitError { AmountOutOfRange, UsersInvalid, PayerUnknown, BpOutOfRange,
#[serde(rename = "bp_sum_not_10000")] BpSumNot10000, // rename_all alone gives "bp_sum_not10000" (D-006)
ExactNegative, ExactSumMismatch, BearerUnknown, ShareUsersMismatch }
pub struct BalanceExpense { pub account_id: String, pub amount_ore: i64, pub payer_user_id: String, pub shares: BTreeMap<String, i64> } pub struct BalanceExpense { pub account_id: String, pub amount_ore: i64, pub payer_user_id: String, pub shares: BTreeMap<String, i64> }
pub struct BalanceSettlement { pub from_user_id: String, pub to_user_id: String, pub allocations: BTreeMap<String, i64> } pub struct BalanceSettlement { pub from_user_id: String, pub to_user_id: String, pub allocations: BTreeMap<String, i64> }
@@ -2183,7 +2201,7 @@ Semantics beyond §9:
#### 14.5.3 Error tokens on the wire and in vectors #### 14.5.3 Error tokens on the wire and in vectors
Snake-case of the enum names: `amount_out_of_range`, `users_invalid`, `payer_unknown`, `bp_out_of_range`, `bp_sum_not_10000`, `exact_negative`, `exact_sum_mismatch`, `bearer_unknown`, `share_users_mismatch`, `no_accounts`, `same_user`, `user_unknown`, `no_allocations`, `too_many_allocations`, `allocation_not_positive`, `duplicate_account`, `allocation_sum_mismatch`. The server puts the token in the `message` of `validation_failed` errors and rejections; the phone uses it as `FieldError.error`. Snake-case of the enum names: `amount_out_of_range`, `users_invalid`, `payer_unknown`, `bp_out_of_range`, `bp_sum_not_10000`, `exact_negative`, `exact_sum_mismatch`, `bearer_unknown`, `share_users_mismatch`, `no_accounts`, `same_user`, `user_unknown`, `no_allocations`, `too_many_allocations`, `allocation_not_positive`, `duplicate_account`, `allocation_sum_mismatch`. The server puts the token in the `message` of `validation_failed` errors and rejections; the phone uses it as `FieldError.error`. The token for a wrong bp sum is exactly `bp_sum_not_10000` on both sides (D-006): the Rust variant carries an explicit `#[serde(rename = "bp_sum_not_10000")]`, and the Kotlin side must emit the same string (the lowercase of `BP_SUM_NOT_10000`), never a token derived by a generic camel-to-snake conversion.
#### 14.5.4 Analytics (Kotlin only) #### 14.5.4 Analytics (Kotlin only)
@@ -2202,10 +2220,10 @@ fun spendSeries(expenses: List<AnalyticsExpense>, range: DateRange, granularity:
fun resolveRange(preset: RangePreset, today: LocalDate, earliestExpense: LocalDate?, customStart: LocalDate?, customEnd: LocalDate?): DateRange fun resolveRange(preset: RangePreset, today: LocalDate, earliestExpense: LocalDate?, customStart: LocalDate?, customEnd: LocalDate?): DateRange
``` ```
(`LocalDate` is `kotlinx.datetime.LocalDate`.) (`LocalDate` is `kotlinx.datetime.LocalDate`. `AnalyticsMeasure.Share` and `se.hagstedt.kvitt.model.Share` share a simple name; accepted, §14.2.4.)
* `spendSeries`: expenses outside the range are ignored. Buckets cover the range completely, in order, including empty ones. Month keys `YYYY-MM`, week keys `YYYY-Www` (ISO week-numbering year, week 1 contains the first Thursday). A bucket's `start`/`endInclusive` are clipped to the range. `totals` maps group id (category id or account id) to öre, omitting zero groups; `total` is their sum. Measure `Household` adds `amountOre`; `Share(u)` adds `shares[u]` (0 if absent). `groupTotals` sums over all buckets, omitting zeros. * `spendSeries`: expenses outside the range are ignored. Buckets cover the range completely, in order, including empty ones. Month keys `YYYY-MM`, week keys `YYYY-Www` (ISO week-numbering year, week 1 contains the first Thursday). A bucket's `start`/`endInclusive` are clipped to the range. `totals` maps group id (category id or account id) to öre, omitting zero groups; `total` is their sum. Measure `Household` adds `amountOre`; `Share(u)` adds `shares[u]` (0 if absent). `groupTotals` sums over all buckets, omitting zeros.
* `resolveRange`: `THIS_MONTH` = first of the current month to `today`; `LAST_3_MONTHS`, `LAST_6_MONTHS`, `LAST_12_MONTHS` = first day of the month N−1 months before the current month to `today`; `THIS_YEAR` = 1 January to `today`; `ALL_TIME` = `earliestExpense` (or `today` when null) to `today`; `CUSTOM` = the custom dates, swapped if start > end. Expenses dated after `today` are therefore excluded from presets; a custom range may include future dates. `CUSTOM` with a null `customStart` or `customEnd` throws an exception of a typed subclass of `IllegalArgumentException` declared in `:domain` (M2b names it); the caller (the `AnalyticsRepository` implementation in `:data`, and its fake) catches it and maps it to `validation_failed` (D-018; no vector covers it); `ALL_TIME` with `earliestExpense` after `today` gives the range `today`..`today`. * `resolveRange`: `THIS_MONTH` = first of the current month to `today`; `LAST_3_MONTHS`, `LAST_6_MONTHS`, `LAST_12_MONTHS` = first day of the month N−1 months before the current month to `today`; `THIS_YEAR` = 1 January to `today`; `ALL_TIME` = `earliestExpense` (or `today` when null) to `today`; `CUSTOM` = the custom dates, swapped if start > end. Expenses dated after `today` are therefore excluded from presets; a custom range may include future dates. `CUSTOM` with a null `customStart` or `customEnd` throws `IncompleteCustomRangeException`, a subclass of `IllegalArgumentException` declared in `:domain` (D-011); the caller (the `AnalyticsRepository` implementation in `:data`, and its fake) catches it and maps it to `validation_failed` (D-018; no vector covers it; in practice the repository resolves a missing custom date as today before calling, §14.2.4); `ALL_TIME` with `earliestExpense` after `today` gives the range `today`..`today`.
#### 14.5.5 Test-vector format #### 14.5.5 Test-vector format
@@ -2343,13 +2361,13 @@ Ownership (an agent edits only paths its milestone owns; anything else needs the
| `server/Cargo.toml`, `server/Cargo.lock`, every crate's `Cargo.toml`, `server/rust-toolchain.toml`, `server/Dockerfile`, `server/crates/kvitt-domain/**` | M2a | contract-change plans only for `kvitt-domain`; M3, M4 and M9 may add dependency lines to a crate's `Cargo.toml` (second to land resolves `Cargo.lock` on rebase) | | `server/Cargo.toml`, `server/Cargo.lock`, every crate's `Cargo.toml`, `server/rust-toolchain.toml`, `server/Dockerfile`, `server/crates/kvitt-domain/**` | M2a | contract-change plans only for `kvitt-domain`; M3, M4 and M9 may add dependency lines 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` (including the `serve` and `migrate` code), `src/db/**`, `src/wire/**`, `src/api/{mod,health,setup}.rs`, `src/cli/{mod,healthcheck}.rs`, M2a's own tests (any `tests/*.rs` without the prefixes `core_`, `sync_`, `scheduler_`, `push_`) | M2a | contract-change plans; M3/M4 may add one line per new module to `mod.rs` files they need; M3 edits the one marked rate-limit line in `router.rs` (D-015) | | `kvitt-server`: `migrations/`, `src/{main,lib,config,error,router,openapi,auth,time}.rs` (including the `serve` and `migrate` code), `src/db/**`, `src/wire/**`, `src/api/{mod,health,setup}.rs`, `src/cli/{mod,healthcheck}.rs`, M2a's own tests (any `tests/*.rs` without the prefixes `core_`, `sync_`, `scheduler_`, `push_`) | M2a | contract-change plans; M3/M4 may add one line per new module to `mod.rs` files they need; M3 edits the one marked rate-limit line in `router.rs` (D-015) |
| `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` | M3 (M2a creates the shells, D-004) | later server plans | | `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` | M3 (M2a creates the shells, D-004) | later server plans |
| `kvitt-server`: `src/api/sync.rs`, `src/tasks/**`, `src/sync/**`, `src/audit/**`, `src/push/**`, `src/scheduler/**`, `src/cli/rotate_epoch.rs`, `tests/sync_*.rs`, `tests/scheduler_*.rs`, `tests/push_*.rs` | M4 (M2a creates the shells, the `tasks::start` stub and the `AuditWriter`/`PushQueue` traits with no-op implementations, D-004, D-015) | later server plans | | `kvitt-server`: `src/api/sync.rs`, `src/tasks/**`, `src/sync/**`, `src/audit/**`, `src/push/**`, `src/scheduler/**`, `src/cli/rotate_epoch.rs`, `tests/sync_*.rs`, `tests/scheduler_*.rs`, `tests/push_*.rs` | M4 (M2a creates the shells, the `tasks::start` stub and the `AuditWriter`/`PushQueue` traits with no-op implementations as described under "Server seams" in §6.3, D-004, D-015) | later server plans; the trait signatures change only with M3, M4 and M9 agreeing through the orchestrator |
| `server/crates/kvitt-import/**` | M9 (M2a creates a crate that builds with a usage-only `main.rs`, D-004) | same | | `server/crates/kvitt-import/**` | M9 (M2a creates a crate that builds with a usage-only `main.rs`, D-004) | same |
| `android/settings.gradle.kts`, `android/build.gradle.kts`, `android/gradle.properties`, `android/gradle/**`, `android/build-logic/**`, the module shells (build file, manifest, one empty entry composable per feature) of `:core:design`, `:data`, `:adapter:fcm` and every `:feature:*` (D-004) | M2b | later plans may add catalog entries and module includes (rebase resolves) | | `android/settings.gradle.kts`, `android/build.gradle.kts`, `android/gradle.properties`, `android/gradle/**`, `android/build-logic/**`, the module shells (build file, manifest, one empty entry composable per feature) of `:core:design`, `:data`, `:adapter:fcm` and every `:feature:*` (D-004) | M2b | later plans may add catalog entries and module includes (rebase resolves) |
| `android/core/model/**`, `android/domain/**` | M2b | contract-change plans only | | `android/core/model/**`, `android/domain/**` | M2b | contract-change plans only |
| `android/core/fakes/**` | M2b | M7x add files only under `fakes/<feature>/` | | `android/core/fakes/**` | M2b | M7x add files only under `fakes/<feature>/` |
| `android/core/design/**` | M6 | M7x may not edit; requests go through the orchestrator | | `android/core/design/**` | M6 | M7x may not edit; requests go through the orchestrator |
| `android/data/**` | M5 | later data plans | | `android/data/**` | M5 (M2b created the Room 3 entities, `KvittDatabase` v1 and the exported schema, D-012) | later data plans; the exported schema is a contract file (§14.8) |
| `android/adapter/fcm/**`, `android/app/**` (after the M2b shell) | M8 | later integration plans | | `android/adapter/fcm/**`, `android/app/**` (after the M2b shell) | M8 | later integration plans |
| `android/feature/onboarding/**` | M7a | | | `android/feature/onboarding/**` | M7a | |
| `android/feature/home/**`, `android/feature/account/**` | M7b | | | `android/feature/home/**`, `android/feature/account/**` | M7b | |
@@ -2369,10 +2387,23 @@ Strings: each feature module has its own `src/main/res/values/strings.xml` and `
### 14.8 Contract files and the change rule ### 14.8 Contract files and the change rule
Contract files (exhaustive): `contracts/openapi.yaml`, `contracts/schema/server-v1.sql` and any later `server-vN.sql`, `contracts/test-vectors/*.json`, `contracts/examples/**`, the Room exported schema `android/data/schemas/**`, `android/core/model/**` (the UI contract and domain types), the sync protocol (§14.4) and the push payload (§14.6). Contract files (exhaustive): `contracts/openapi.yaml`, `contracts/schema/server-v1.sql` and any later `server-vN.sql`, `contracts/test-vectors/*.json`, `contracts/examples/**`, the Room exported schema `android/data/schemas/**` (version 1 exists since M2b, D-012), `android/core/model/**` (the UI contract and domain types), the sync protocol (§14.4) and the push payload (§14.6).
Representational edits to `contracts/openapi.yaml` (key order, `$ref` naming, description wording) do not change meaning and need no spec bump or decision entry; they are listed in the report of the plan that made them (D-016). The OpenAPI equality test compares parsed values, not text. Representational edits to `contracts/openapi.yaml` (key order, `$ref` naming, description wording) do not change meaning and need no spec bump or decision entry; they are listed in the report of the plan that made them (D-016). The OpenAPI equality test compares parsed values, not text.
The equality test (`server/crates/kvitt-server/tests/openapi_contract.rs`, D-005) parses both documents and compares them after exactly these normalisations, every one representational; anything else that differs fails the test:
1. Key order is ignored (objects compared as maps).
2. Every local `$ref` is inlined, discriminator `mapping` values included, so component naming and placement do not matter.
3. `description` and `example`/`examples` keywords are dropped (properties named `description` are kept; `summary` and `operationId` are compared).
4. A nullable type array `type: [T, "null"]` equals `oneOf` with `{type: "null"}`; nested `oneOf`s are flattened and compared as sets.
5. `const: v` equals a single-value `enum: [v]`; `enum` values and `required` lists are compared as sets.
6. `required: false` and `deprecated: false` are dropped; a path parameter is always required.
7. Parameters are keyed by (`in`, `name`).
8. Each operation is compared on its effective security (its own `security`, else the document's).
The document-wide rules of §14.3.1 and §14.3.2 live in one place on the generated side, the `CommonRules` modifier in `server/crates/kvitt-server/src/openapi.rs`, never in a handler annotation: the common request headers and the `X-Kvitt-Server-Version` response header on every operation, the always-possible error responses of §14.3.2 (authenticated and unauthenticated sets), the document-level `device` security, and response headers marked required.
Rule: a change to any other contract file, or a semantic change to `contracts/openapi.yaml` or to §14 of this spec, requires, landed together by the orchestrator: a spec version bump (in the header and the change log), a `docs/decisions.md` entry, and the matching implementation changes on both sides, or a plan that schedules them. A schema change also bumps the schema version (`meta.schema_version` and a new migration on the server; a new Room version and migration on the phone). A sync protocol change bumps `X-Kvitt-Protocol`; a push payload change bumps `v`. Numbers come from the orchestrator's brief. Implementation agents never edit `SPEC.md` or `docs/decisions.md`: they put the proposed spec text and decision entry in their report, and the orchestrator has a docs agent record them (CLAUDE.md › Orchestration). Rule: a change to any other contract file, or a semantic change to `contracts/openapi.yaml` or to §14 of this spec, requires, landed together by the orchestrator: a spec version bump (in the header and the change log), a `docs/decisions.md` entry, and the matching implementation changes on both sides, or a plan that schedules them. A schema change also bumps the schema version (`meta.schema_version` and a new migration on the server; a new Room version and migration on the phone). A sync protocol change bumps `X-Kvitt-Protocol`; a push payload change bumps `v`. Numbers come from the orchestrator's brief. Implementation agents never edit `SPEC.md` or `docs/decisions.md`: they put the proposed spec text and decision entry in their report, and the orchestrator has a docs agent record them (CLAUDE.md › Orchestration).
--- ---
@@ -2384,3 +2415,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 0.1 | 2026-10-17 | First version, written from the owner interview: scope, requirements, data model, stack, API outline, security, business rules, deployment, milestones, UX sprint brief, contracts (schema v1, Room v1, OpenAPI outline, sync protocol v1, domain functions and vectors, push payload v1, ownership map). | | 0.1 | 2026-10-17 | First version, written from the owner interview: scope, requirements, data model, stack, API outline, security, business rules, deployment, milestones, UX sprint brief, contracts (schema v1, Room v1, OpenAPI outline, sync protocol v1, domain functions and vectors, push payload v1, ownership map). |
| 0.2 | 2026-10-17 | Owner answers. D-001: development-first deployment on the Mac; M2c split into M2c-dev (now) and M2c-pi (after M8, before M10); registry is Gitea at `http://gitea.lan` (OQ-1 answered). D-002: local backup files deleted as soon as confirmed on Drive. D-003: Splitwise CSV format confirmed for English and Swedish exports, Swedish category names mapped, accounts "Hemma" and "Vardag" (OQ-2 answered). OQ-4 answered (Drive folder id). §10, §11, §12, §14.7 updated. Clarifications from M1 (no new decisions): non-deleted rows only into domain functions, analytics vector keys, sync push result key sets, E-1 503 body, optional/nullable request fields of E-3, E-12, E-17, E-33, `since` required on E-31, proposal amount range, bp above 10000, `resolveRange` edge cases, §9.6 limits not in JSON Schema, example directory names, `not_implemented` on no operation (§14.3, §14.4.3, §14.5). | | 0.2 | 2026-10-17 | Owner answers. D-001: development-first deployment on the Mac; M2c split into M2c-dev (now) and M2c-pi (after M8, before M10); registry is Gitea at `http://gitea.lan` (OQ-1 answered). D-002: local backup files deleted as soon as confirmed on Drive. D-003: Splitwise CSV format confirmed for English and Swedish exports, Swedish category names mapped, accounts "Hemma" and "Vardag" (OQ-2 answered). OQ-4 answered (Drive folder id). §10, §11, §12, §14.7 updated. Clarifications from M1 (no new decisions): non-deleted rows only into domain functions, analytics vector keys, sync push result key sets, E-1 503 body, optional/nullable request fields of E-3, E-12, E-17, E-33, `since` required on E-31, proposal amount range, bp above 10000, `resolveRange` edge cases, §9.6 limits not in JSON Schema, example directory names, `not_implemented` on no operation (§14.3, §14.4.3, §14.5). |
| 0.3 | 2026-10-17 | Orchestrator resolutions of the plans' open points. D-004: M2a and M2b create shell files and modules in later milestones' paths, ownership of `Cargo.lock` and every `Cargo.toml` (§11.2, §11.3, §14.7). D-015: server seams: middleware in `router.rs`, rate limit in `src/api/rate_limit.rs` on one marked line, `src/tasks/` for M4, `AuditWriter`/`PushQueue` traits with no-op implementations, `next_occurrence_date` and day clamping in `kvitt-domain` (Rust only), M3 writes the recurring generation function, M4 lands after M3 (§6.3, §9.5, §11, §14.5.2, §14.7). D-016: the OpenAPI equality test compares parsed values; representational edits need no spec bump (§11.3, §14.8). D-017: feature convention, analytics types in `se.hagstedt.kvitt.model.analytics`, string placement and key prefixes, `MissingTranslation` in every module (§6.2, §13.4, §14.5.4, §14.7). D-018: empty fake scenario; `resolveRange` throws a typed `IllegalArgumentException` subclass for an incomplete `CUSTOM` range (§6.2, §14.5.4). D-019: ledger rows and expense editor fields in `:core:design` (§6.2, §11.2). D-020: no bundled font, Material 3 default typeface (§6.2, §13.4). D-021: `sync_mutations` pruning daily at 04:00 (§14.1, §14.4.9). D-022: development infrastructure as M2c-dev built it (`c885aba`): Firebase through the REST API with a gcloud token, no Firebase CLI, secrets and `google-services.json` in `infra/secrets/` copied by `infra/dev/copy-google-services.sh`, Firebase resources and the `kvitt-fcm` service account (`roles/firebasecloudmessaging.admin`) exist; dev stack without Caddy or TLS, bind-mounted data, `infra/secrets/` mounted at `/secrets`, env file `infra/dev/.env.example`, `healthcheck` used by compose; the server starts when `KVITT_TRUSTED_PROXY` does not resolve; new M2c-dev gate (§10 intro, §10.4, §10.7, §11.2, §14.7). D-023: M2c-pi allowances, HUMAN steps, `.gitea/workflows/`, secret modes for uid 1000 (§10.2, §10.3, §11.2, §14.7). D-024: release keystore command, kept outside the repository (§10.6). D-025: production import procedure and the `/opt/kvitt/import` bind mount (§10.2, §10.8). Without a decision id: exact Rust version pin shared by `rust-toolchain.toml` and the Dockerfile (§10.3); the §9.7 colour change lands before M6 (§11.1); per-screen states are U2's list (§13.3); landing page copy (§13.3, AC-2.5); M8 owns network security config, hardening, signing and version properties (§11.2); U1's gate names the six phase-1 screens (§11.2); category table path (§10.8); implementation agents never edit the spec or the decisions log (§14.8). | | 0.3 | 2026-10-17 | Orchestrator resolutions of the plans' open points. D-004: M2a and M2b create shell files and modules in later milestones' paths, ownership of `Cargo.lock` and every `Cargo.toml` (§11.2, §11.3, §14.7). D-015: server seams: middleware in `router.rs`, rate limit in `src/api/rate_limit.rs` on one marked line, `src/tasks/` for M4, `AuditWriter`/`PushQueue` traits with no-op implementations, `next_occurrence_date` and day clamping in `kvitt-domain` (Rust only), M3 writes the recurring generation function, M4 lands after M3 (§6.3, §9.5, §11, §14.5.2, §14.7). D-016: the OpenAPI equality test compares parsed values; representational edits need no spec bump (§11.3, §14.8). D-017: feature convention, analytics types in `se.hagstedt.kvitt.model.analytics`, string placement and key prefixes, `MissingTranslation` in every module (§6.2, §13.4, §14.5.4, §14.7). D-018: empty fake scenario; `resolveRange` throws a typed `IllegalArgumentException` subclass for an incomplete `CUSTOM` range (§6.2, §14.5.4). D-019: ledger rows and expense editor fields in `:core:design` (§6.2, §11.2). D-020: no bundled font, Material 3 default typeface (§6.2, §13.4). D-021: `sync_mutations` pruning daily at 04:00 (§14.1, §14.4.9). D-022: development infrastructure as M2c-dev built it (`c885aba`): Firebase through the REST API with a gcloud token, no Firebase CLI, secrets and `google-services.json` in `infra/secrets/` copied by `infra/dev/copy-google-services.sh`, Firebase resources and the `kvitt-fcm` service account (`roles/firebasecloudmessaging.admin`) exist; dev stack without Caddy or TLS, bind-mounted data, `infra/secrets/` mounted at `/secrets`, env file `infra/dev/.env.example`, `healthcheck` used by compose; the server starts when `KVITT_TRUSTED_PROXY` does not resolve; new M2c-dev gate (§10 intro, §10.4, §10.7, §11.2, §14.7). D-023: M2c-pi allowances, HUMAN steps, `.gitea/workflows/`, secret modes for uid 1000 (§10.2, §10.3, §11.2, §14.7). D-024: release keystore command, kept outside the repository (§10.6). D-025: production import procedure and the `/opt/kvitt/import` bind mount (§10.2, §10.8). Without a decision id: exact Rust version pin shared by `rust-toolchain.toml` and the Dockerfile (§10.3); the §9.7 colour change lands before M6 (§11.1); per-screen states are U2's list (§13.3); landing page copy (§13.3, AC-2.5); M8 owns network security config, hardening, signing and version properties (§11.2); U1's gate names the six phase-1 screens (§11.2); category table path (§10.8); implementation agents never edit the spec or the decisions log (§14.8). |
| 0.4 | 2026-10-17 | Decisions from the M2a (`fdf2093`) and M2b (`fad6fc9`) implementation reports. D-005: the eight normalisations of the OpenAPI equality test and the `CommonRules` modifier in `openapi.rs` (§11.3, §14.8). D-006: the wrong-bp-sum token is `bp_sum_not_10000` with an explicit serde rename in Rust; Kotlin emits the same token (§14.5.1, §14.5.3). D-007: the no-unwrap rule enforced per crate with `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]` (CLAUDE.md). D-010: screenshot tests with Roborazzi 1.76.0 on Robolectric 4.17, `@Config(sdk = [36])`, `roborazzi.test.verify=true`, re-record with `recordRoborazziDebug` (§6.2, §11.2). D-011: `IncompleteCustomRangeException` for an incomplete `CUSTOM` range; `ALL_TIME` with the earliest expense after today gives today..today (§14.5.4). D-012: Room 3 (`androidx.room3` 3.0.3, KSP only, `SQLiteDriver` with `sqlite-bundled`, migrations on an `SQLiteConnection`); M2b created the entities and `KvittDatabase` v1, whose exported schema `1.json` is a contract file; M5 builds on them (§6.2, §11.2, §11.3, §14.2, §14.2.3, §14.7, §14.8). D-013: JDK 25 `--add-exports`/`--add-opens` for Robolectric in `build-logic` (§6.2). D-014: `failOnNoDiscoveredTests` off only for modules without `src/test` (§6.2). D-008 and D-009 unused. Clarifications without a decision id: exact Rust pin (1.99.1) shared by `rust-toolchain.toml` and the Dockerfile, `TARGETARCH` arm64 (shipped) and amd64, distroless pinned by digest (§6.3, §10.3); chrono stands (§6.3); `kvitt-domain` depends only on `serde` (§6.3, CLAUDE.md); `AuditWriter` and `PushQueue` as built (§6.3, §14.7); protocol check before token check (§6.3, §14.3.1); 405 with an empty body and the version header (§14.3.1); body limits as router layers (§8.6); `app_version_code` updated only from a present, parseable `X-Kvitt-App-Version` (§8.2); E-1 `schema_version` from a code constant; E-2 `initialized` means an admin user exists (§14.3.4); `healthcheck` uses the port of `KVITT_BIND`; `cargo run -p kvitt-server -- <command>` from the workspace root (§10.4); `minSdk` 36, `compileSdk` and `targetSdk` 37, Material 3 1.4.0 pinned beside the Compose BOM, AGP 9 `check` runs only the debug unit-test variant, version code and name from `kvitt.versionCode`/`kvitt.versionName` defaulting to `1`/`dev`, feature convention documented in `android/README.md` (§6.2); `FieldError.error` may be `validation_failed` for §9.6 rules without a domain token (§9.6, §14.2.4); `AnalyticsState` has no error field and a missing custom date resolves as today (§14.2.4, §14.5.4); `DrillDownState.title` stays a `String` of unlocalised parts and S-12 formats its own title (§14.2.4); a settlement row in an account's list shows that account's allocation (AC-11.7, §14.2.4); the `AnalyticsMeasure.Share` / `model.Share` name collision is accepted (§14.2.4, §14.5.4). |
+38 -28
View File
@@ -3,42 +3,52 @@
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this. Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
## 1. Where things stand ## 1. Where things stand
- `main` at `c885aba` (M2c-dev) on top of `5e1be38` (plans, prompts, SPEC v0.2); after this docs change lands, `main` also carries SPEC v0.3 (branch `agent-spec3`). Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2c-dev infra for development (`c885aba`). No server or Android project yet, so neither gate runs. - `main` at `fdf2093` (M2a) plus the SPEC v0.4 docs commit from `agent-spec4` once it lands. History: `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `fad6fc9` M2b, `fdf2093` M2a.
- Worktrees in flight under `.claude/worktrees/`: `agent-m2a` (M2a server skeleton and `kvitt-domain`), `agent-m2b` (M2b Android skeleton and `:domain`), `agent-spec3` (this docs change: SPEC v0.3, D-004, D-015 to D-025). Both M2 agents started from `5e1be38`; their briefs carried the orchestrator's resolutions that v0.3 now records. - Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2c-dev infra for development (`c885aba`), SPEC v0.3 (`76ee957`), M2b Android skeleton and `:domain` (`fad6fc9`), M2a server skeleton and `kvitt-domain` (`fdf2093`). With `agent-spec4`: SPEC v0.4 with D-005 to D-007 and D-010 to D-014 (D-008 and D-009 unused).
- The owner runs the UX sprint (U1, then U2) in a separate Claude session in the worktree `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint` (based on `cab3e93`, SPEC v0.1); it commits only under `docs/ux/` and rebases on `main` before landing. - Both gates exist now: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy -- -D warnings && cargo test` (from `server/`).
- Development infrastructure exists (M2c-dev): `infra/dev/` compose stack on the Mac (server and on-demand backup, no Caddy, no TLS), Firebase on project `kvitt-1337` with Android apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`, service account `kvitt-fcm` with `roles/firebasecloudmessaging.admin`; `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` in the main checkout (git-ignored). The dev stack's health check from the phone is a HUMAN step and waits for M2a's image. - Worktrees in flight under `.claude/worktrees/`: `agent-m3` (M3 server core API), `agent-m5` (M5 Android data layer), `agent-spec4` (this docs change: SPEC v0.4).
- Installed on the owner's phone: nothing. - The owner runs the UX sprint (U1, then U2) in a separate Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`; it commits only under `docs/ux/` and rebases on `main` before landing.
- Development infrastructure (M2c-dev): `infra/dev/` compose stack on the Mac (server built from `server/Dockerfile`, on-demand backup, no Caddy, no TLS); Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`; `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` in the main checkout (git-ignored). The M2a image now exists, so the phone health check can run.
- Installed on the owner's phone: nothing yet (M2b's `installDebug` is pending).
## 2. Open work (one line each, with who owns it: agent or owner) ## 2. Open work (one line each, with who owns it: agent or owner)
- Land `agent-spec3` (orchestrator): SPEC v0.3, decisions D-004 and D-015 to D-025, plans and prompts with every open point closed, CLAUDE.md numbering rule (implementation agents never edit SPEC or decisions). - Land `agent-spec4` (orchestrator): SPEC v0.4, decisions D-005 to D-014, CLAUDE.md server rules (D-007, `kvitt-domain` deps), PLAN-M2a/M2b "on record", PLAN-M5 (builds on M2b's entities, D-012), v0.4 readings in PLAN-M3, M5, M7b to M7f, prompts/README status.
- Then record M2a's and M2b's proposed decisions from their reports (D-005 to D-014, including D-010 the screenshot tool) through a docs agent (orchestrator). - Tell `agent-m5` before it lands (orchestrator): M2b already created the Room 3 entities, `KvittDatabase` v1 and `1.json` (D-012); M5 adds DAOs and must not change them; v0.4 readings of §14.2.4 (FieldError `validation_failed`, missing custom date = today, `DrillDownState.title`, settlement row = allocation in an account's list).
- Plans (SPEC §11.2 v0.3), in dependency order; prompt `prompts/<id>.md`: - Follow-up in `:core:fakes` (M2b-owned base files; needs a small agent or goes with M5's or M7b's brief): settlement rows in an account's ledger must carry the allocation, not the total (AC-11.7 v0.4); replace the five `TODO(spec)` comments in `android/core/fakes/**` that v0.4 settles; and the `TODO(spec)` in `server/crates/kvitt-domain/src/split.rs` (D-006).
- Plans (SPEC §11.2 v0.4), in dependency order; prompt `prompts/<id>.md`:
- M1 Contracts and vectors: landed (`c6df2ab`). - M1 Contracts and vectors: landed (`c6df2ab`).
- M2c-dev Infra for development: landed (`c885aba`); outstanding: phone `/health` check (owner) once M2a's image exists. - M2a Server skeleton and `kvitt-domain`: landed (`fdf2093`).
- M2b Android skeleton and `:domain`: landed (`fad6fc9`); outstanding: `installDebug` on the phone (owner plugs it in).
- M2c-dev Infra for development: landed (`c885aba`); outstanding: phone `/health` check against the dev stack (owner).
- U1 UX directions: owner's session (`UX-sprint`), in progress; owner then picks a direction. - U1 UX directions: owner's session (`UX-sprint`), in progress; owner then picks a direction.
- U2 UX design spec: owner's session (`UX-sprint`), after U1 and the pick; owner approves; then the §9.7 colour change goes into the spec (docs agent) before M6. - U2 UX design spec: owner's session, after U1 and the pick; owner approves; then the §9.7 colour change goes into the spec (docs agent) before M6.
- M2a Server skeleton and `kvitt-domain`: agent in flight (`agent-m2a`). - M3 Server core API: agent in flight (`agent-m3`).
- M2b Android skeleton and `:domain`: agent in flight (`agent-m2b`); plug the phone in once for `installDebug` (owner). - M4 Server sync, audit, push, scheduler: can start now (after M2a); lands after M3 (D-015).
- M3 Server core API: after M2a. - M5 Android data layer: agent in flight (`agent-m5`).
- M4 Server sync, audit, push, scheduler: after M2a, lands after M3 (D-015). - M6 Design system: after U2 approved and the §9.7 colour change.
- M5 Android data layer: after M2b.
- M6 Design system: after U2 approved, M2b and the §9.7 colour change.
- M7a to M7f Features: after M6, in parallel. - M7a to M7f Features: after M6, in parallel.
- M9 Splitwise importer: after M2a and M4. - M9 Splitwise importer: after M4.
- M8 App integration: after M3, M4, M5, M7a to M7f, M2c-dev. - M8 App integration: after M3, M4, M5, M7a to M7f.
- M2c-pi Infra on the Pi: after M8, before M10 (owner: answer OQ-3, grant the Pi, Home Assistant Pi, Gitea and Drive allowances at the start, D-023). - M2c-pi Infra on the Pi: after M8, before M10 (owner: answer OQ-3, grant the Pi, Home Assistant Pi, Gitea and Drive allowances at the start, D-023).
- M10 First release: after M2c-pi, M3, M4, M8 (owner: keystore per §10.6). - M10 First release: after M2c-pi, M3, M4, M8 (owner: keystore per §10.6, D-024).
- M11 Import history: after M9 and M10. - M11 Import history: after M9 and M10.
## 3. Owner questions (asked, unanswered) ## 3. HUMAN steps pending (relay in chat)
- OQ-3 LAN addresses and SSH users of the Kvitt Pi and the Home Assistant Pi; SSH port of the HA Pi; certificate directory there; which SSH add-on (SPEC §12). Needed by M2c-pi only. (OQ-1, OQ-2, OQ-4 are answered: D-001, D-003, §10.9.) - Plug the phone in over USB so an agent can run `installDebug` of the M2b skeleton (expected: the Kvitt debug app opens to an empty themed screen).
- Dev-stack health check: on the phone on the home Wi-Fi, open the dev server's `/health` and answer yes/no that it shows `"status":"ok"` (M2c-dev).
- UX direction pick from U1's directions, with comments (owner's UX-sprint session).
## 4. Paused (waiting on something outside the repo) ## 4. Owner questions (asked, unanswered)
- M2c-dev phone health check: waits for M2a's server image. - OQ-3 LAN addresses and SSH users of the Kvitt Pi and the Home Assistant Pi; SSH port of the HA Pi; certificate directory there; which SSH add-on (SPEC §12). Needed by M2c-pi only. The only open question (OQ-1, OQ-2, OQ-4 answered: D-001, D-003, §10.9).
## 5. Numbering ## 5. Paused (waiting on something outside the repo)
Next free after `agent-spec3`: decision **D-026** (D-001 to D-004 and D-015 to D-025 are used; D-005 to D-014 are reserved for the M2a and M2b agents' proposals, D-010 being the screenshot tool). Spec version: v0.3 written; next **v0.4**. Schema version **1** (defined, not yet implemented); next schema version **2**. Sync protocol **1**, push payload **1**. - Nothing; the M2c-dev phone check no longer waits on M2a, only on the owner.
## 6. How the work is run ## 6. Numbering
See CLAUDE.md › Orchestration. HUMAN steps in reports are relayed in chat by the orchestrator; the owner does not - Decisions: D-001 to D-025 used (D-008 and D-009 unused). Reserved: **D-026 to D-029 for M5**, **D-030 to D-039 for M3**. Next free for anything else: **D-040**.
read them in files. - Spec version: v0.4 written; next **v0.5** (the first of M3/M5 to land takes it; the second renumbers on rebase).
- Server schema version **1** (implemented by M2a); next **2**. Room schema version **1** (created by M2b, `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**.
- Sync protocol **1**, push payload **1**.
## 7. How the work is run
See CLAUDE.md › Orchestration. HUMAN steps in reports are relayed in chat by the orchestrator; the owner does not read them in files.
+11 -1
View File
@@ -9,10 +9,20 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| D-002 | 2026-10-17 | Backup retention on the Pi: local snapshot files are deleted as soon as their upload to Drive is confirmed (`rclone lsf`), not after 30 days. Drive keeps everything. | Owner's choice; the Pi's disk is small and Drive is the archive. | SPEC §10.9 step 5; PLAN-M2c-pi | | D-002 | 2026-10-17 | Backup retention on the Pi: local snapshot files are deleted as soon as their upload to Drive is confirmed (`rclone lsf`), not after 30 days. Drive keeps everything. | Owner's choice; the Pi's disk is small and Drive is the archive. | SPEC §10.9 step 5; PLAN-M2c-pi |
| D-003 | 2026-10-17 | Splitwise CSV format confirmed from two real exports (OQ-2 answered). English variant header `Date,Description,Category,Cost,Currency,Amelie Bennich,Jacob Hagstedt` (group "hemma", account "Hemma"); Swedish variant header `Datum,Beskrivning,Kategori,Kostnad,Valuta,Jacob Hagstedt,Amelie Bennich` (general group, account "Vardag", about 1750 rows). Member columns differ in order, so the importer reads member names from the header. ISO dates, point decimal, double-quoted fields with commas, trailing empty line then `Total balance` / `Totalsumma`. Payment category `Payment` / `Betalning`. All rows SEK. Swedish category names are mapped through a committed table (unknown names to `other`, counted as a warning). The partner's display name is Amelie. The CSV files stay in `export/` (git-ignored), never committed or quoted. | Real exports differ by Splitwise UI language. | SPEC §10.8, §12; PLAN-M9 (mapping notes), PLAN-M11 | | D-003 | 2026-10-17 | Splitwise CSV format confirmed from two real exports (OQ-2 answered). English variant header `Date,Description,Category,Cost,Currency,Amelie Bennich,Jacob Hagstedt` (group "hemma", account "Hemma"); Swedish variant header `Datum,Beskrivning,Kategori,Kostnad,Valuta,Jacob Hagstedt,Amelie Bennich` (general group, account "Vardag", about 1750 rows). Member columns differ in order, so the importer reads member names from the header. ISO dates, point decimal, double-quoted fields with commas, trailing empty line then `Total balance` / `Totalsumma`. Payment category `Payment` / `Betalning`. All rows SEK. Swedish category names are mapped through a committed table (unknown names to `other`, counted as a warning). The partner's display name is Amelie. The CSV files stay in `export/` (git-ignored), never committed or quoted. | Real exports differ by Splitwise UI language. | SPEC §10.8, §12; PLAN-M9 (mapping notes), PLAN-M11 |
| D-004 | 2026-10-17 | Shells and ownership. M2a creates shell files in M3, M4 and M9 paths (501 handlers, stub subcommands `bootstrap`, `pair-device`, `rotate-epoch`, a `kvitt-import` crate whose `main.rs` prints usage); M2b creates a module shell for every module of §6.2 with one empty entry composable per feature. The later milestone owns each shell from the moment it lands and writes the body. M2a owns `server/Cargo.lock`, every `Cargo.toml`, `main.rs`, `lib.rs`, config, `serve` and `migrate`, `router.rs` and its own tests (any `tests/*.rs` without the `core_`, `sync_`, `scheduler_`, `push_` prefixes); later milestones may add dependency lines to `Cargo.toml`. | PLAN-M2a and PLAN-M2b open points (a), (b), (c): the skeletons need files in paths §14.7 gave to later milestones. | SPEC §11.2, §11.3, §14.7; PLAN-M2a, PLAN-M2b, PLAN-M3, PLAN-M4, PLAN-M6, PLAN-M7a to M7f, PLAN-M9 | | D-004 | 2026-10-17 | Shells and ownership. M2a creates shell files in M3, M4 and M9 paths (501 handlers, stub subcommands `bootstrap`, `pair-device`, `rotate-epoch`, a `kvitt-import` crate whose `main.rs` prints usage); M2b creates a module shell for every module of §6.2 with one empty entry composable per feature. The later milestone owns each shell from the moment it lands and writes the body. M2a owns `server/Cargo.lock`, every `Cargo.toml`, `main.rs`, `lib.rs`, config, `serve` and `migrate`, `router.rs` and its own tests (any `tests/*.rs` without the `core_`, `sync_`, `scheduler_`, `push_` prefixes); later milestones may add dependency lines to `Cargo.toml`. | PLAN-M2a and PLAN-M2b open points (a), (b), (c): the skeletons need files in paths §14.7 gave to later milestones. | SPEC §11.2, §11.3, §14.7; PLAN-M2a, PLAN-M2b, PLAN-M3, PLAN-M4, PLAN-M6, PLAN-M7a to M7f, PLAN-M9 |
| D-005 | 2026-10-17 | The OpenAPI equality test (`server/crates/kvitt-server/tests/openapi_contract.rs`) parses both documents and compares values after eight normalisations: key order ignored; local `$ref` inlined, discriminator mappings included; `description` and `example` keywords dropped; nullable type arrays equal `oneOf` with `null`, nested `oneOf` flattened and compared as sets; `const` equals a single-value `enum`; `required: false` and `deprecated: false` dropped, path parameters always required; parameters keyed by (`in`, `name`); each operation compared on its effective security. The document-wide rules of §14.3.1 and §14.3.2 live in one place, the `CommonRules` modifier in `src/openapi.rs`. | M2a report (`fdf2093`): utoipa's output differs from the hand-written YAML only in form; D-016 needed the exact list. | SPEC §11.3, §14.8 |
| D-006 | 2026-10-17 | The §14.5.3 token for a wrong basis-point sum is `bp_sum_not_10000`. The Rust variant `SplitError::BpSumNot10000` carries an explicit `#[serde(rename = "bp_sum_not_10000")]`, because `rename_all = "snake_case"` gives `bp_sum_not10000`. The Kotlin side must emit the same token. | M2a report: the §14.5.1 snippet would not produce the token the vectors expect. | SPEC §14.5.1, §14.5.3 |
| D-007 | 2026-10-17 | The no-`unwrap`/`expect` rule for server non-test code is enforced per crate with `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]` at each crate root. | M2a report: a rule the gate checks instead of one reviewers remember. | CLAUDE.md (Architecture rules) |
| D-008 | 2026-10-17 | Unused (reserved for M2a and M2b; neither needed it). | | |
| D-009 | 2026-10-17 | Unused (reserved for M2a and M2b; neither needed it). | | |
| D-010 | 2026-10-17 | Screenshot tests use Roborazzi 1.76.0 on Robolectric 4.17 with `@Config(sdk = [36])`; `roborazzi.test.verify=true` in `android/gradle.properties` makes `check` compare with the committed goldens; re-record with `tools/gradle-serial.sh recordRoborazziDebug`. | M2b report (`fad6fc9`): AGP's Compose Preview Screenshot Testing plugin was still alpha. | SPEC §6.2, §11.2, §11.3; android/README.md |
| D-011 | 2026-10-17 | `resolveRange` with `CUSTOM` and a null start or end throws `IncompleteCustomRangeException` (subclass of `IllegalArgumentException`) declared in `:domain`; callers map it to `validation_failed`. `ALL_TIME` with the earliest expense after today gives `today`..`today`. Completes D-018. | M2b report: D-018 left the name to M2b. | SPEC §11.3, §14.5.4 |
| D-012 | 2026-10-17 | Room 3 (`androidx.room3` 3.0.3, KSP only, `SQLiteDriver` with `sqlite-bundled`; migrations take an `SQLiteConnection`) instead of Room 2. M2b created the Room entities and `KvittDatabase` version 1 in `:data`; the exported schema `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` is a contract file (§14.8). M5 builds on the existing entities; changing them or the exported schema is a contract change. | M2b report: §14.2 named Room without a major version; M2b chose Room 3 and created the entities and version 1 with the skeleton, so §11.2 and PLAN-M5 had to follow. | SPEC §6.2, §11.2 (M2b, M5), §11.3, §14.2, §14.2.3, §14.7, §14.8; PLAN-M5 |
| D-013 | 2026-10-17 | Android unit tests run with `--add-exports java.base/jdk.internal.access` and `--add-opens java.base/java.io` on JDK 25 for Robolectric, set in `build-logic` for every Android module. | M2b report: needed by Robolectric on the pinned JDK 25. | SPEC §6.2 |
| D-014 | 2026-10-17 | Gradle's `failOnNoDiscoveredTests` is off only for modules without `src/test` (the shells) and on everywhere else. | M2b report: the shells have no tests yet; in every other module a test task that discovers nothing must fail the gate. | SPEC §6.2 |
| D-015 | 2026-10-17 | Server seams. The `X-Kvitt-Protocol` 426 check, the `X-Kvitt-Server-Version` header and the §8.6 body limits are layers in `router.rs` (M2a). M3's rate limit is a layer in `src/api/rate_limit.rs` (replacing `src/ratelimit.rs`) attached at one line in `router.rs` that M2a marks. M2a creates `src/tasks/mod.rs` with a `start` function called from `serve`; M4 owns `src/tasks/**`. M2a implements `next_occurrence_date` and the day clamping in `kvitt-domain` (Rust only, `src/recurring.rs`) and defines the traits `AuditWriter` (`src/audit/`) and `PushQueue` (`src/push/`) with no-op implementations; M4 replaces the implementations, M3 and M9 call the traits. Filled in by the docs agent: M3 writes the per-template recurring generation function in `src/api/recurring.rs` (E-28, E-29 and M4's scheduler call it), M3 tests the §9.5 generation cases, M4 the scheduler cases, and M4 lands after M3. | PLAN-M2a (d), (e); PLAN-M3 (a), (c); PLAN-M4 (a), (b): M3 and M4 both need §9.5 logic, audit and push while running in parallel. | SPEC §6.3, §9.5, §11.1, §11.2, §14.5, §14.7; PLAN-M2a, PLAN-M3, PLAN-M4, PLAN-M9 | | D-015 | 2026-10-17 | Server seams. The `X-Kvitt-Protocol` 426 check, the `X-Kvitt-Server-Version` header and the §8.6 body limits are layers in `router.rs` (M2a). M3's rate limit is a layer in `src/api/rate_limit.rs` (replacing `src/ratelimit.rs`) attached at one line in `router.rs` that M2a marks. M2a creates `src/tasks/mod.rs` with a `start` function called from `serve`; M4 owns `src/tasks/**`. M2a implements `next_occurrence_date` and the day clamping in `kvitt-domain` (Rust only, `src/recurring.rs`) and defines the traits `AuditWriter` (`src/audit/`) and `PushQueue` (`src/push/`) with no-op implementations; M4 replaces the implementations, M3 and M9 call the traits. Filled in by the docs agent: M3 writes the per-template recurring generation function in `src/api/recurring.rs` (E-28, E-29 and M4's scheduler call it), M3 tests the §9.5 generation cases, M4 the scheduler cases, and M4 lands after M3. | PLAN-M2a (d), (e); PLAN-M3 (a), (c); PLAN-M4 (a), (b): M3 and M4 both need §9.5 logic, audit and push while running in parallel. | SPEC §6.3, §9.5, §11.1, §11.2, §14.5, §14.7; PLAN-M2a, PLAN-M3, PLAN-M4, PLAN-M9 |
| D-016 | 2026-10-17 | The OpenAPI equality test compares parsed values, not text. Representational edits to `contracts/openapi.yaml` (key order, `$ref` naming, description wording) need no spec bump or decision entry; they are listed in the plan report. | PLAN-M2a open point (f); utoipa output differs from hand-written YAML in form only. | SPEC §11.3, §14.8; CLAUDE.md (Contracts are versioned); PLAN-M2a | | D-016 | 2026-10-17 | The OpenAPI equality test compares parsed values, not text. Representational edits to `contracts/openapi.yaml` (key order, `$ref` naming, description wording) need no spec bump or decision entry; they are listed in the plan report. | PLAN-M2a open point (f); utoipa output differs from hand-written YAML in form only. | SPEC §11.3, §14.8; CLAUDE.md (Contracts are versioned); PLAN-M2a |
| D-017 | 2026-10-17 | Feature convention. Each feature exposes one composable entry point per screen taking a `hiltViewModel()`-provided ViewModel and navigation as plain lambdas; features never reference other features or the `NavHost`; `:app` owns the `NavHost`; repositories are injected through the `:core:model` interfaces, bound in `:app` to `:data` (fakes only in test and debug-preview source sets). The §14.5.4 analytics types live in `:core:model`, package `se.hagstedt.kvitt.model.analytics`. Strings: `:app` holds only the app name, `:core:design` the shared strings (the notification copy and channel name included), each feature its own; lint `MissingTranslation` is an error in every module; keys are prefixed by the feature name (§14.7 wins over §13.4, which said "by screen"); `:core:design` keys never start with a feature name. | PLAN-M2b open points (c), (e), (f); M7a to M7f need one convention fixed in `build-logic`. | SPEC §6.2, §13.4, §14.5.4, §14.7; PLAN-M2b, PLAN-M6, PLAN-M7a to M7f, PLAN-M8, PLAN-U2 | | D-017 | 2026-10-17 | Feature convention. Each feature exposes one composable entry point per screen taking a `hiltViewModel()`-provided ViewModel and navigation as plain lambdas; features never reference other features or the `NavHost`; `:app` owns the `NavHost`; repositories are injected through the `:core:model` interfaces, bound in `:app` to `:data` (fakes only in test and debug-preview source sets). The §14.5.4 analytics types live in `:core:model`, package `se.hagstedt.kvitt.model.analytics`. Strings: `:app` holds only the app name, `:core:design` the shared strings (the notification copy and channel name included), each feature its own; lint `MissingTranslation` is an error in every module; keys are prefixed by the feature name (§14.7 wins over §13.4, which said "by screen"); `:core:design` keys never start with a feature name. | PLAN-M2b open points (c), (e), (f); M7a to M7f need one convention fixed in `build-logic`. | SPEC §6.2, §13.4, §14.5.4, §14.7; PLAN-M2b, PLAN-M6, PLAN-M7a to M7f, PLAN-M8, PLAN-U2 |
| D-018 | 2026-10-17 | The empty fake scenario: household complete (two named users, no pictures), no accounts, exactly the ten default categories, no expenses, settlements, templates, rejections or pending changes. `resolveRange` with `CUSTOM` and a null start or end throws a typed subclass of `IllegalArgumentException` declared in `:domain` (named by M2b); the caller maps it to `validation_failed`. | PLAN-M2b open point (g) and the `resolveRange` signalling left open in v0.2. | SPEC §6.2, §11.3, §14.5.4; PLAN-M2b | | D-018 | 2026-10-17 | The empty fake scenario: household complete (two named users, no pictures), no accounts, exactly the ten default categories, no expenses, settlements, templates, rejections or pending changes. `resolveRange` with `CUSTOM` and a null start or end throws a typed subclass of `IllegalArgumentException` declared in `:domain` (named by M2b: `IncompleteCustomRangeException`, D-011); the caller maps it to `validation_failed`. | PLAN-M2b open point (g) and the `resolveRange` signalling left open in v0.2. | SPEC §6.2, §11.3, §14.5.4; PLAN-M2b |
| D-019 | 2026-10-17 | Shared UI needed by two features is built in `:core:design` by M6: the ledger rows (expense and settlement rows, used by S-5, S-12 and the pending list of S-16) and the expense editor fields (used by S-7 and S-19). Features never copy each other's components. | PLAN-M6 open point (a); feature modules cannot depend on each other. | SPEC §6.2, §11.2, §11.3; PLAN-M6, PLAN-M7b, M7c, M7e, M7f | | D-019 | 2026-10-17 | Shared UI needed by two features is built in `:core:design` by M6: the ledger rows (expense and settlement rows, used by S-5, S-12 and the pending list of S-16) and the expense editor fields (used by S-7 and S-19). Features never copy each other's components. | PLAN-M6 open point (a); feature modules cannot depend on each other. | SPEC §6.2, §11.2, §11.3; PLAN-M6, PLAN-M7b, M7c, M7e, M7f |
| D-020 | 2026-10-17 | No bundled font: the app uses the Material 3 default typeface. No licence step. | PLAN-M6 open point (b); avoids a font licence clearance. | SPEC §6.2, §11.2, §13.4; PLAN-M2b, PLAN-M6, PLAN-U1, PLAN-U2 | | D-020 | 2026-10-17 | No bundled font: the app uses the Material 3 default typeface. No licence step. | PLAN-M6 open point (b); avoids a font licence clearance. | SPEC §6.2, §11.2, §13.4; PLAN-M2b, PLAN-M6, PLAN-U1, PLAN-U2 |
| D-021 | 2026-10-17 | `sync_mutations` pruning runs daily at 04:00 Europe/Stockholm (DST-aware), deleting rows whose `received_at` is older than 90 days. | PLAN-M4 open point (c); the spec said only "daily". | SPEC §14.1 (rules text, not the DDL), §14.4.9, §11.2; PLAN-M4 | | D-021 | 2026-10-17 | `sync_mutations` pruning runs daily at 04:00 Europe/Stockholm (DST-aware), deleting rows whose `received_at` is older than 90 days. | PLAN-M4 open point (c); the spec said only "daily". | SPEC §14.1 (rules text, not the DDL), §14.4.9, §11.2; PLAN-M4 |
+3 -3
View File
@@ -2,7 +2,7 @@
One ready-to-paste start prompt per plan (`PLAN-<id>.md` at the repository root), plus `ORCHESTRATOR-prompt.md`. Before pasting, the orchestrator fills `{{NEXT_DECISION}}`, `{{SPEC_VERSION}}`, `{{SCHEMA_VERSION}}` (disjoint ranges for agents that run in parallel, CLAUDE.md › Numbering) and `{{ORCHESTRATOR_NOTES}}` (answers to the plan's open points, phone assignment, overrides). `U2.md` also takes `{{OWNER_PICK_AND_COMMENTS}}`. U1 and U2 run in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`. The numbers are for proposed entries in agents' reports: implementation agents never edit `SPEC.md` or `docs/decisions.md`; a docs agent records what the orchestrator accepts (CLAUDE.md › Orchestration). One ready-to-paste start prompt per plan (`PLAN-<id>.md` at the repository root), plus `ORCHESTRATOR-prompt.md`. Before pasting, the orchestrator fills `{{NEXT_DECISION}}`, `{{SPEC_VERSION}}`, `{{SCHEMA_VERSION}}` (disjoint ranges for agents that run in parallel, CLAUDE.md › Numbering) and `{{ORCHESTRATOR_NOTES}}` (answers to the plan's open points, phone assignment, overrides). `U2.md` also takes `{{OWNER_PICK_AND_COMMENTS}}`. U1 and U2 run in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`. The numbers are for proposed entries in agents' reports: implementation agents never edit `SPEC.md` or `docs/decisions.md`; a docs agent records what the orchestrator accepts (CLAUDE.md › Orchestration).
Dependencies follow SPEC §11.1 and §11.2 (v0.3). Status as of 2026-10-17: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`); M2a and M2b in flight; U1 in the owner's session. The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4. Dependencies follow SPEC §11.1 and §11.2 (v0.4). Status as of 2026-10-17: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`fdf2093`), M2b landed (`fad6fc9`); M3 and M5 in flight; U1 in the owner's session. The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014).
## Dependency order and waves ## Dependency order and waves
@@ -12,8 +12,8 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7
|---|---|---|---|---| |---|---|---|---|---|
| 0 | M1 Contracts and vectors | `M1.md` | M0 | done (`c6df2ab`) | | 0 | M1 Contracts and vectors | `M1.md` | M0 | done (`c6df2ab`) |
| 0 | U1 UX directions | `U1.md` | M0 | everything in waves 0 to 2 (owner's session) | | 0 | U1 UX directions | `U1.md` | M0 | everything in waves 0 to 2 (owner's session) |
| 1 | M2a Server skeleton and `kvitt-domain` | `M2a.md` | M1 | M2b, M2c-dev, U1/U2 | | 1 | M2a Server skeleton and `kvitt-domain` | `M2a.md` | M1 | done (`fdf2093`) |
| 1 | M2b Android skeleton and `:domain` | `M2b.md` | M1 | M2a, M2c-dev, U1/U2 | | 1 | M2b Android skeleton and `:domain` | `M2b.md` | M1 | done (`fad6fc9`); `installDebug` on the phone outstanding |
| 1 | M2c-dev Infra for development | `M2c-dev.md` | M0 (its health check needs M2a's image) | done (`c885aba`) | | 1 | M2c-dev Infra for development | `M2c-dev.md` | M0 (its health check needs M2a's image) | done (`c885aba`) |
| 1 | U2 UX design spec | `U2.md` | U1 and the owner's pick | M2a, M2b, M2c-dev, wave 2 | | 1 | U2 UX design spec | `U2.md` | U1 and the owner's pick | M2a, M2b, M2c-dev, wave 2 |
| 2 | M3 Server core API | `M3.md` | M2a | M4 (M4 lands after M3), M5, M6 | | 2 | M3 Server core API | `M3.md` | M2a | M4 (M4 lands after M3), M5, M6 |