diff --git a/SPEC.md b/SPEC.md index 8378fc0..70b673d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.17** (2026-10-18). Schema version **3**. Sync protocol version **1**. Push payload version **1**. +Version **0.18** (2026-10-18). Schema version **3**. 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. @@ -120,9 +120,9 @@ The server starts with an empty database. The household is created from the comm * AC-2.3 An invite is single-use and expires 72 hours after creation. An expired, cancelled, used or unknown code is refused with `invite_invalid`; the response does not reveal which. * AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the kind of the accepted invite (`invite_kind`, D-086), the user, the device and the household's users. For a `partner` invite it creates the `member` user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050). * AC-2.5 `GET /i/` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=&code=` (followed by `&from=` when the request carries a valid `from` query parameter, D-087), the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data of its own: the `from` name is only passed on into the `kvitt://invite` link and never shown on the page; an invalid `from` (not UTF-8 after percent-decoding, a control character, or more than 40 code points after trimming) is dropped and the page still opens (D-087). While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404". -* AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https://icehack.asuscomm.com:8443/i/[?from=…]` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. When the link names the inviting admin (`from`, E-12, D-087), S-1's help line names them (`onboarding_code_help`); links without a name (older links) still open, with the generic help line. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). +* AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https://icehack.asuscomm.com:8443/i/[?from=…]` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. When the link names the inviting admin (`from`, E-12, D-087), S-1's help line names them (`onboarding_code_help`); links without a name (older links) still open, with the generic help line. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). A link may name any server: as built, S-1 opens its server row expanded when the link's server is not the release default (`RELEASE_SERVER_URL`, `https://icehack.asuscomm.com:8443`), so the address is visible and editable before pairing; v1 adds no confirmation step for an unusual server (security review L-6, D-116; a confirm step would need a design state, OQ-6). * AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `https://icehack.asuscomm.com:8443` in release builds) and the code. Codes are normalised per §8.3 before sending. -* AC-2.8 Release builds accept only `https://` server URLs. Debug builds also accept `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`-`172.31.*`, `192.168.*`). The limit is enforced in code by `SessionRepositoryImpl.normaliseServerUrl`, not by the network security config, which cannot express IP ranges (§8.5, D-082). +* AC-2.8 Release builds accept only `https://` server URLs. Debug builds also accept `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`-`172.31.*`, `192.168.*`). The limit is enforced in code by `SessionRepositoryImpl.normaliseServerUrl`, not by the network security config, which cannot express IP ranges (§8.5, D-082). A release build therefore pairs with any `https://` server a link or the user names; what S-1 shows then is in AC-2.6 (D-116). * AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android `POST_NOTIFICATIONS`). As built (M8), the request is made after the first successful sync after pairing. * AC-2.10 A `partner` pairing (E-3 `invite_kind` = `partner`, D-086) continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen. Every other pairing goes to Home, including a member pairing a second phone with a `device` invite. * AC-2.11 The admin sees pending invites (kind, created, expiry) in Settings and can cancel each. @@ -141,13 +141,13 @@ The server starts with an empty database. The household is created from the comm * AC-4.1 Each user has a display name (limits §9.6) and an optional profile picture. * AC-4.2 A user edits their own display name in Settings (online only). The change reaches the other phone through sync. * AC-4.3 A user sets their own picture from the Android photo picker (no camera capture in v1) or removes it. Upload is online only. -* AC-4.4 The server accepts JPEG, PNG and WebP up to 5 MiB and at most 8000 × 8000 pixels, applies EXIF orientation, centre-crops to a square, re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128, strips all metadata, and increments the user's `picture_version` (the first upload is version 1; after a removal the next version follows the highest one ever used, §10.4, D-030). PNG and WebP transparency is flattened onto white (D-034). Other input is refused with `image_invalid`, `unsupported_media_type` or `payload_too_large`. +* AC-4.4 The server accepts JPEG, PNG and WebP up to 5 MiB and at most 8000 × 8000 pixels, and also at most 16 000 000 pixels (width × height, for example 4000 × 4000) decoding to at most 64 MB (16 MP × 4 bytes, so a 16-bit image is refused above 8 MP), checked from the image header before decoding with the decoder's limits set as well, else `image_invalid` (D-114); it applies EXIF orientation, centre-crops to a square, re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128, strips all metadata, and increments the user's `picture_version` (the first upload is version 1; after a removal the next version follows the highest one ever used, §10.4, D-030). PNG and WebP transparency is flattened onto white (D-034). Other input is refused with `image_invalid`, `unsupported_media_type` or `payload_too_large`. * AC-4.5 Where no picture exists, the app shows the user's initials (first letter of the first two words of the display name, uppercase) on one of two colour tokens, `avatar-1` and `avatar-2` (values in U2's `docs/ux/design-spec.md`). A user's slot is a stable function of the user id, so the same user has the same colour on both phones (D-052). * AC-4.6 The app caches pictures on disk (Coil) keyed by user id and `picture_version`, so they show offline. ### FR-5 Household gate -* AC-5.1 Until the partner has joined, the admin's Home screen shows "Waiting for your partner" with the "Invite partner" action, and creating accounts, expenses, settlements and recurring templates is not offered. +* AC-5.1 Until the partner has joined, the admin's Home screen shows "Waiting for your partner" with the "Invite partner" action, and creating accounts, expenses, settlements and recurring templates is not offered. In that state `SettlementRepository.start` and `recompute` return null and `save` returns `SaveResult.Invalid` with a `payer` `validation_failed` error; nothing throws (§14.2.4, D-111). * AC-5.2 The server refuses account creation, recurring template creation and any expense or settlement mutation with `household_incomplete` while fewer than two users exist. (The rule exists because every split refers to both users.) ### FR-6 Expense accounts @@ -250,8 +250,9 @@ The server starts with an empty database. The household is created from the comm * AC-13.4 One `POST /sync` that produces more than 3 notifiable changes sends one `sync_batch` push instead of one per change. * AC-13.5 The app shows the notification only after it has synced the change, built from local data in the phone's language (title and body copy in §13). If that sync fails within 20 seconds, it shows a generic text without amounts or descriptions. * AC-13.6 Tapping a notification opens the detail view of the changed expense or settlement (S-8 or S-10). It opens Home for deletions, for `sync_batch` and for the generic notification (shown when the sync failed or the entity or its actor is missing locally) (D-085). -* AC-13.7 The app registers for push by Firebase Installation ID (D-083): it calls `register()` whenever it is paired and at each app start while paired, and every id Firebase hands to `onRegistered(fid)` is sent as `fcm_token` with `PUT /devices/current/push-token` (E-17). The server sends pushes to that id in the FCM HTTP v1 `message.fid` field (§14.6, D-096). When the device is revoked (AC-3.3) the app calls `unregister()`. +* AC-13.7 The app registers for push by Firebase Installation ID (D-083): it calls `register()` whenever it is paired and at each app start while paired, and every id Firebase hands to `onRegistered(fid)` is sent as `fcm_token` with `PUT /devices/current/push-token` (E-17). The server sends pushes to that id in the FCM HTTP v1 `message.fid` field (§14.6, D-096). When the device is revoked (AC-3.3) the app calls `unregister()`. A failed E-17 send (network error, 429, 5xx) is kept and sent again after each successful sync until the server accepts it; a newer id from `onRegistered` replaces the kept one (`PushTokenRetry` in `:app`, D-112). * AC-13.8 No personal content (names, amounts, descriptions, notes) is placed in the FCM payload. +* AC-13.9 Notifications are posted with `VISIBILITY_PRIVATE` and a public version carrying the generic text "Kvitt" / "Open Kvitt to see it" (`notif_generic_title`, `notif_generic_body`), so a secure lock screen shows no description, amount, account or name (security review L-7, D-116). ### FR-14 Analytics @@ -300,7 +301,7 @@ The server starts with an empty database. The household is created from the comm ### FR-19 Localisation and formatting -* AC-19.1 Every user-visible string is a resource in `values/strings.xml` (English, the fallback) and `values-sv/strings.xml` (Swedish). No string literal appears in feature code. A lint or test in the Android gate fails on a key missing from either file. +* AC-19.1 Every user-visible string is a resource in `values/strings.xml` (English, the fallback) and `values-sv/strings.xml` (Swedish). No string literal appears in feature code. A lint or test in the Android gate fails on a key missing from either file. The APK carries only these two languages: `android/app/build.gradle.kts` sets `androidResources.localeFilters` to `en`, `sv` (G-8; `2bd6b58`), so libraries' translations into other languages are not packaged. * AC-19.2 Money formatting per §9.8, dates per §9.8. * AC-19.3 Category default names and every enum shown to users have strings in both languages. @@ -406,11 +407,11 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2). | Background | WorkManager (periodic 15 min sync, expedited sync) | Survives process death, respects Doze | | Dates | kotlinx-datetime | Allowed in pure modules (no `java.time`) | | Images | Coil (Compose) with an auth header interceptor | Disk cache for offline avatars | -| Charts | Compose `Canvas` in `:core:design`, no chart library (D-063). Vico 3.3.1 cannot give the per-bar TalkBack nodes and tap targets, 2 dp segment gaps, corner radii on the whole bar (10/4 at M6; top 4, bottom 2 dp since D-097) and an axis gutter growing at 200 % that `docs/ux/design-spec.md` §3.10 requires. Vico was removed from the version catalog on 2026-10-18 (`2b4e21c`); it was never used | The three charts are small; the design spec's accessibility rules need per-bar control | +| Charts | Compose `Canvas` in `:core:design`, no chart library (D-063). Vico 3.3.1 cannot give the per-bar TalkBack nodes and tap targets, 2 dp segment gaps, corner radii on the whole bar (10/4 at M6; top 4, bottom 2 dp since D-097) and an axis gutter growing at 200 % that `docs/ux/design-spec.md` §3.10 requires. Vico was removed from the version catalog on 2026-10-18 (`a312758`); it was never used | The three charts are small; the design spec's accessibility rules need per-bar control | | Push | Firebase Cloud Messaging (firebase-messaging 26.0.0 through the Firebase BoM 35.0.2, google-services plugin 4.5.0; registration by Firebase Installation ID, D-083), 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 | | 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 with Roborazzi (1.76.0 at M2b) on Robolectric (4.17) with `@Config(sdk = [36])` (D-010), Room tests (DAOs and migrations) on Robolectric with the framework SQLite driver, migration tests built from the exported schema JSON rather than `room3-testing` (D-042), 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`) | +| Tests | JUnit, Compose screenshot tests with Roborazzi (1.76.0 at M2b) on Robolectric (4.17) with `@Config(sdk = [36])` (D-010), Room tests (DAOs and migrations) on Robolectric with the framework SQLite driver, migration tests built from the exported schema JSON rather than `room3-testing` (D-042), Ktor `MockEngine`, WorkManager's test `WorkManager` from `androidx.work:work-testing` (a `testImplementation` of `:data`, the same version as `work-runtime`, `c366fbd`). 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 | | App icon | Adaptive launcher icon, vectors only (D-095): `android/app/src/main/res/mipmap-anydpi/ic_launcher.xml` with background colour `#9C5238` (the light theme's primary, `values/ic_launcher_colors.xml`) and foreground and monochrome layers (`drawable/ic_launcher_foreground.xml`, `drawable/ic_launcher_monochrome.xml`) drawing the `receipt_long` app mark at 48 dp, centred in the 108 dp canvas; the manifest's `android:icon` and `android:roundIcon` point at it, which clears lint `MissingApplicationIcon` | No raster assets; themed icons use the monochrome layer | @@ -420,7 +421,7 @@ Module graph (directory `android/`): |---|---|---|---| | `:core:model` | pure Kotlin/JVM | Domain types (§14.5.1), analytics types (§14.5.4, package `se.hagstedt.kvitt.model.analytics`), `*State` classes, repository interfaces (§14.2.4) | kotlin, kotlinx | | `:domain` | pure Kotlin/JVM | Split, balance, allocation, analytics functions (§14.5); test-vector tests | `:core:model` | -| `:core:design` | Android library | Built by M6 (`3080804`) from `docs/ux/design-spec.md` §2 and §3: `KvittTheme`, tokens (`KvittColors`, `KvittShapes`, `KvittMotion`), typography, shared components (money text, avatar, category tile and pickers, balance header, sync pill, banners, charts drawn with `Canvas`, D-063; the ledger rows for expenses and settlements used by S-5, S-12 and S-16; the expense editor fields used by S-7 and S-19, D-019), `MoneyFormatter`, `DateFormatter`, shared strings | `:core:model` | +| `:core:design` | Android library | Built by M6 (`7730a31`) from `docs/ux/design-spec.md` §2 and §3: `KvittTheme`, tokens (`KvittColors`, `KvittShapes`, `KvittMotion`), typography, shared components (money text, avatar, category tile and pickers, balance header, sync pill, banners, charts drawn with `Canvas`, D-063; the ledger rows for expenses and settlements used by S-5, S-12 and S-16; the expense editor fields used by S-7 and S-19, D-019), `MoneyFormatter`, `DateFormatter`, shared strings | `:core:model` | | `:core:fakes` | Android library, used only by tests, screenshot tests and debug previews | Scenario-driven fakes of every repository interface (they call `:domain` for computed values so fakes never disagree with the real rules) | `:core:model`, `:domain` | | `:data` | Android library | Room DB, DAOs, entities, Ktor API client and DTOs, sync engine, WorkManager workers, token store, repository implementations, APK updater | `:core:model`, `:domain` | | `:adapter:fcm` | Android library | `FirebaseMessagingService`, registration by Firebase Installation ID (D-083), maps messages to `PushEvent` (`:core:model`, `model/Push.kt`, moved there by M12e, D-095) through `PushMapping`; `PushListener` stays in the adapter | `:core:model` | @@ -433,9 +434,9 @@ Module graph (directory `android/`): | `: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 | -**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. Golden layout (D-098, M8c, revises D-066): three goldens per component sheet or screen state, `_sv_light.png`, `_sv_dark.png` and `_en_light.png` (English covers string length, dark covers the theme; there is no `en_dark`), plus `_sv_light_200.png` at 200 % font scale for the long states; `:app`'s `empty_screen_.png` are the only other names. The content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Every capture renders at 1x density through the Robolectric qualifier `w411dp-hdp-mdpi`, so a full screen of 411 × 891 dp is 411 × 891 px. Every committed PNG (goldens and the `docs/ux/` mock-ups) is compressed losslessly with `oxipng -o 4 --strip safe` before committing (`7c453d8`); Roborazzi verifies against the compressed files. Feature modules follow the same layout: each feature module has Roborazzi screenshot tests of its screen states with goldens in `src/test/screenshots/`, rendered full screen. The reduction took the goldens from 1143 PNGs and 131 MB to 865 and 26 MB, the mock-ups from 36.8 to 26.0 MB, for a small repository on the home Gitea server. +**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. Golden layout (D-098, M8c, revises D-066): three goldens per component sheet or screen state, `_sv_light.png`, `_sv_dark.png` and `_en_light.png` (English covers string length, dark covers the theme; there is no `en_dark`), plus `_sv_light_200.png` at 200 % font scale for the long states; `:app`'s `empty_screen_.png` are the only other names. The content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Every capture renders at 1x density through the Robolectric qualifier `w411dp-hdp-mdpi`, so a full screen of 411 × 891 dp is 411 × 891 px. Every committed PNG (goldens and the `docs/ux/` mock-ups) is compressed losslessly with `oxipng -o 4 --strip safe` before committing (`7c453d8`); Roborazzi verifies against the compressed files. Feature modules follow the same layout: each feature module has Roborazzi screenshot tests of its screen states with goldens in `src/test/screenshots/`, rendered full screen. A screen harness renders one more frame and idles the main looper before each capture; without that `:feature:home` and `:feature:account` captured the previous variant, so their committed `sv_dark` (and some `_sv_light_200` and dialog) goldens were one variant stale, fixed and re-recorded in `dc7a296` and `5b2ed37`. The reduction took the goldens from 1143 PNGs and 131 MB to 865 and 26 MB, the mock-ups from 36.8 to 26.0 MB, for a small repository on the home Gitea server. -**Hilt wiring (D-081, M8).** Both variants bind every `:core:model` interface to `:data`: `android/app/src/main/.../di/AppDataModule.kt` includes `DataBindingsModule` (D-041), and `AppBuildInfo` comes from `BuildConfig` in both variants. The debug fakes module (`72f21b2`) and the release-only data module were deleted by M8; no Hilt module lives in `src/debug` or `src/release`. Fakes live only in test source sets and feature previews and never reach an installed build; `:app`'s unit tests run on a plain `Application`, not the Hilt application. +**Hilt wiring (D-081, M8).** Both variants bind every `:core:model` interface to `:data`: `android/app/src/main/.../di/AppDataModule.kt` includes `DataBindingsModule` (D-041), and `AppBuildInfo` comes from `BuildConfig` in both variants. The debug fakes module (`a41f240`) and the release-only data module were deleted by M8; no Hilt module lives in `src/debug` or `src/release`. Fakes live only in test source sets and feature previews and never reach an installed build; `:app`'s unit tests run on a plain `Application`, not the Hilt application. 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`. @@ -477,7 +478,7 @@ Cargo workspace (directory `server/`): **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`. 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. As M4 built it (D-039): `tasks::start` returns the `AppState` with the real push queue installed, and `serve` builds the router from that returned state. +* **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. As M4 built it (D-039): `tasks::start` returns the `AppState` with the real push queue installed, and `serve` builds the router from that returned state. Since the server overhaul (`6c405e1`) it is a synchronous `fn start(AppState) -> AppState` (it never awaited or failed); D-039 holds. * **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. Since M4 (D-039): `AppState::new` holds `SqlAuditWriter` by default and `NoopAuditWriter` no longer exists; `NoopPushQueue` stays as the queue when push is disabled (§14.6), and `tasks::start` installs the bounded `PushHub` otherwise. 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. @@ -501,7 +502,7 @@ Base URL: `https://icehack.asuscomm.com:8443` (the value of `KVITT_PUBLIC_URL`). | # | Method | Path | Auth | Purpose | |---|---|---|---|---| -| E-1 | GET | `/health` | none | Liveness and DB check | +| E-1 | GET | `/health` | none | Liveness and DB check; S-22 shows its `server_version` and `schema_version` (D-110) | | E-2 | GET | `/setup/status` | none | Whether the household has been bootstrapped | | E-3 | POST | `/invites/accept` | none | Pair a device with an invite code (all three invite kinds) | | E-4 | GET | `/i/{code}` | none | Invite landing page (HTML) | @@ -587,7 +588,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. * Authenticated endpoints: at most 600 requests per minute per device. -* Keying (D-032): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A request answered 401 also counts against its client IP's unauthenticated limit, and a token that was never accepted is refused with `rate_limited` once that IP is over its limit. All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (`KVITT_TRUSTED_PROXY`, re-resolved at most every 60 seconds), the last entry of `X-Forwarded-For`. Per-IP keying needs the peer address: `serve` runs the router with `into_make_service_with_connect_info::()` (added by M4 in `bd6f10a`, D-032 fulfilled). +* Keying (D-032, revised by D-115): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A token is accepted only when a response to it is 2xx. A request with a token not yet accepted counts against its client IP's unauthenticated limit before it runs and is refused with `rate_limited` once the IP is over; a 2xx gives that count back (a newly paired device costs its IP nothing); a 401 for an accepted token counts against the IP and withdraws the acceptance; token entries, accepted or not, are pruned once their window expires; every 401 counts against the IP exactly once (security review H-1: before, a random token answered 426 without `X-Kvitt-Protocol` became accepted for good, bypassed the IP limit and was never pruned). All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (`KVITT_TRUSTED_PROXY`, re-resolved at most every 60 seconds), the last entry of `X-Forwarded-For`. Per-IP keying needs the peer address: `serve` runs the router with `into_make_service_with_connect_info::()` (added by M4 in `15056dc`, D-032 fulfilled). * `GET /health` from the container healthcheck counts against the loopback IP's limit; at the compose healthcheck interval this stays far below 60 per minute and is accepted. * Invite accept failures: AC-2.12. * 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). @@ -598,8 +599,10 @@ Two trusted users. The server is on the public internet on port 8443. Threats in * Server logs contain: timestamp, level, request method, route template (not the raw path with codes), status, latency, device id, error code. They never contain tokens, invite codes, display names, descriptions, notes, amounts or picture bytes. * The phone never logs personal content, debug builds included (`CLAUDE.md`). Log tags use the prefix `Kvitt`. +* Caddy's access log and its error log replace the `/i/` path segment and every query string (the inviter's name in `?from=`) with `REDACTED` through a `request>uri` filter; the `Authorization` header is redacted by Caddy's default (§10.5.2, D-117). +* The push sender logs one info line `push sent` per message with aggregates only: `push_type`, `devices_sent`, `devices_targeted` and the last 8 characters of each device id that got it; the per-device line is at debug (D-113). * FCM payloads carry ids and types only (§14.6). -* Backups on Google Drive are not encrypted in v1; they go to the owner's own Drive folder, reachable only by his Google account. +* Backups on Google Drive are not encrypted in v1; they go to the owner's own Drive folder, reachable only by his Google account. Whether to encrypt them with an rclone `crypt` remote and narrow the token to the `drive.file` scope is an open owner decision (security review M-1, `docs/validation/security-review.md`). ### 8.8 Phone hardening @@ -610,6 +613,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in ### 8.9 Server hardening * The server container runs as uid/gid `10001`, with a read-only root filesystem, `/data` as the only writable volume, `/secrets` read-only, no Linux capabilities, `no-new-privileges`. +* The `caddy` container runs as uid 10002 with supplementary group 1000, a read-only root filesystem, every capability dropped except `NET_BIND_SERVICE`, `no-new-privileges` and tmpfs `/config` and `/tmp` (§10.2, D-117). * Only Caddy publishes a port (8443). The server, Caddy's admin API and the sidecars are reachable only on the internal compose network. * SQLite `PRAGMA foreign_keys = ON`, `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5000` on every connection. @@ -732,6 +736,7 @@ All string lengths are counted in Unicode code points after trimming. One table, | category `name` | 1 to 40 code points, or `null` for a default category | `validation_failed` | | user `display_name` | 1 to 40 code points | `validation_failed` | | device `name` | 1 to 60 code points | `validation_failed` | +| `fcm_token` (E-17) | 1 to 512 characters after trimming, or `null` to clear; a Firebase Installation ID has 22 (D-114) | `invalid_request` with field `fcm_token` | | `day_of_month` | integer 1 to 31 | `validation_failed` | | `start_period` | `YYYY-MM`, not earlier than 12 months before the current month | `validation_failed` | | `end_period` | `null` or `YYYY-MM` ≥ `start_period` | `validation_failed` | @@ -859,9 +864,9 @@ Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expe All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `infra/secrets/` on the Mac (git-ignored) and in `/opt/kvitt/secrets/` on the Pi; neither is ever committed. -**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts the whole `infra/secrets/` folder read-only at `/secrets` instead of a single key file (a missing `fcm-service-account.json` means push is disabled, §10.4); its env file is `infra/dev/.env.example`; its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env template is `infra/pi/.env.example` (D-103). The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup, and to schema 3 (migration 0003, §14.1.2) on 2026-10-18 when it was rebuilt from `90505a8`, again after a backup. On the Mac's Podman setup `infra/dev/up.sh` alone does not swap in a rebuilt image: run `infra/dev/up.sh --down`, then `infra/dev/up.sh`. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10. +**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts `infra/dev/secrets/` (git-ignored) read-only at `/secrets`, into which `infra/dev/up.sh` copies only `fcm-service-account.json` from `infra/secrets/`, so the server container sees no other secret (D-117; a folder rather than the file, so a missing key leaves the folder empty and push disabled, §10.4); its env file is `infra/dev/.env.example` (no `RCLONE_REMOTE` since `fc6126c`: the development backup never uploads); its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env template is `infra/pi/.env.example` (D-103). The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup, and to schema 3 (migration 0003, §14.1.2) on 2026-10-18 when it was rebuilt from `b7b7483`, again after a backup. On the Mac's Podman setup `infra/dev/up.sh` alone does not swap in a rebuilt image: run `infra/dev/up.sh --down`, then `infra/dev/up.sh`. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10. -**No SSH from the Mac to the Pis (D-100, revises D-023).** M2c-pi delivers a self-contained bundle in `infra/pi/`: the compose file, the Caddyfile, an env template, install, deploy, bootstrap and backup scripts and a README checklist. The owner copies the bundle to the Kvitt Pi, fills the placeholders in its `.env` (the OQ-3 values among them) and runs the scripts there. Agents never connect to the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive and need no allowance for them; they check the bundle on the Mac (`docker compose config` with the env template, `shellcheck`). Where §10.2 to §10.10 have a script on the Mac act on the Pi (for example `infra/deploy.sh` and `infra/publish-apk.sh` over SSH), that step becomes a bundle script the owner runs on the Pi; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. D-001 stands: development stays on the Mac. The bundle landed on 2026-10-18 (`5e027df`); its layout, scripts and paths are D-103 to D-106 and are written into §10.2 to §10.10 below; the owner's checklist is `infra/README.md` "Pi: what the owner does" (nine steps). +**No SSH from the Mac to the Pis (D-100, revises D-023).** M2c-pi delivers a self-contained bundle in `infra/pi/`: the compose file, the Caddyfile, an env template, install, deploy, bootstrap and backup scripts and a README checklist. The owner copies the bundle to the Kvitt Pi, fills the placeholders in its `.env` (the OQ-3 values among them) and runs the scripts there. Agents never connect to the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive and need no allowance for them; they check the bundle on the Mac (`docker compose config` with the env template, `shellcheck`). Where §10.2 to §10.10 have a script on the Mac act on the Pi (for example `infra/deploy.sh` and `infra/publish-apk.sh` over SSH), that step becomes a bundle script the owner runs on the Pi; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. D-001 stands: development stays on the Mac. The bundle landed on 2026-10-18 (`2476667`); its layout, scripts and paths are D-103 to D-106 and are written into §10.2 to §10.10 below; the owner's checklist is `infra/README.md` "Pi: what the owner does" (nine steps). ### 10.1 Hosts and network @@ -890,23 +895,24 @@ File `infra/pi/compose.yaml` (compose project name `kvitt`), installed by `infra * `caddy` `depends_on` `cert-sync` with `condition: service_healthy` and `server` with `condition: service_started`. * Every service: `restart: unless-stopped`, logging driver `json-file` with `max-size: 10m`, `max-file: 3`, `TZ=Europe/Stockholm`. * One internal bridge network `kvitt`. -* The `cert-sync` container runs as uid 1000; its secret files on the Pi (`/opt/kvitt/secrets/ha_ssh_key`, `ha_known_hosts`) have mode 0600 and are owned by 1000:1000 (D-023). The `backup` container runs as uid 10001, the server's uid, on the Pi as on the development stack, because `sqlite3 .backup` on the WAL database must open the `-shm` file the server owns; so `/opt/kvitt/secrets/rclone.conf` has mode 0600 and is owned by 10001:10001 (D-105, revises D-023 for this file). rclone writes refreshed access tokens back to its config, and the mounted file is read-only, so `backup.sh` works on a temporary copy of it on every run (D-105). `install.sh` gives the named volumes their owners once (`/data`, `/data/apk`, `/backups` to 10001, `/certs` to 1000). The FCM key keeps the mode of §10.7 (0440, root:10001). +* The `cert-sync` container runs as uid 1000; its secret files on the Pi (`/opt/kvitt/secrets/ha_ssh_key`, `ha_known_hosts`) have mode 0600 and are owned by 1000:1000 (D-023). The `backup` container runs as uid 10001, the server's uid, on the Pi as on the development stack, because `sqlite3 .backup` on the WAL database must open the `-shm` file the server owns; so `/opt/kvitt/secrets/rclone.conf` has mode 0600 and is owned by 10001:10001 (D-105, revises D-023 for this file). rclone writes refreshed access tokens back to its config, and the mounted file is read-only, so `backup.sh` works on a temporary copy of it on every run (D-105). `install.sh` gives the named volumes their owners once (`/data`, `/data/apk`, `/backups` to 10001; `/certs` to 1000:1000 with mode 0750; Caddy's `caddy_data` to 10002, D-117, extending D-105). The FCM key keeps the mode of §10.7 (0440, root:10001). +* The `caddy` container is non-root (D-117): `user: "10002:10002"` with `group_add: ["1000"]` (cert-sync's group, to read the 0640 files in the 0750 `/certs`), `read_only: true`, `cap_drop: [ALL]` and `cap_add: [NET_BIND_SERVICE]`, `no-new-privileges`, tmpfs `/config` and `/tmp`. Port 8443 needs no capability, but the image's `/usr/bin/caddy` carries the file capability `cap_net_bind_service=ep` and the kernel refuses to exec it when that capability is not in the bounding set (checked 2026-10-18), so it is the one capability kept. `.env` keys (exhaustive, D-103). Read by `compose.yaml`: `KVITT_REGISTRY` (`gitea.lan/jacobh2`), `KVITT_TAG`, `HA_HOST`, `HA_PORT`, `HA_USER`, `HA_CERT_DIR`, `RCLONE_REMOTE` (default `gdrive:`), `RCLONE_FOLDER_ID`. Read by `install.sh` only, never by the containers: `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (empty for anonymous pull). Informational, used in the owner's `scp` commands on the Mac and read by nothing on the Pi: `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. Unfilled values are `CHANGE_ME` placeholders; `install.sh` refuses a `.env` that still contains one. ### 10.3 Images, registry and build * Image names (D-104): `${KVITT_REGISTRY}/kvitt-server`, `${KVITT_REGISTRY}/kvitt-cert-sync`, `${KVITT_REGISTRY}/kvitt-backup`, with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/`). -* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host` (an act_runner on a Raspberry Pi 3 with 1 GB RAM; the builds run on that Pi itself), `sh` steps, Alpine `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with the repository secret `kvitt_cicd` (created by the owner), a plain-HTTP buildx builder `local-http-builder` (`buildkitd.toml` with `http = true` for `gitea.lan`) created once and reused so its BuildKit cache survives, `actions/checkout@v4` with `fetch-depth: 0`. Triggers: a push touching `server/**`, `infra/cert-sync/**`, `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch`. Version `v..` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is what the deploy script takes; it says nothing about the APK version (§10.6). The Pi runner passes `--build-arg CARGO_BUILD_JOBS=1` to the server build (Pi 3, 1 GB), and `server/Dockerfile` honours it (`ARG CARGO_BUILD_JOBS` in its build stage, passed to cargo as `--jobs` only when set; unset on the Mac means one job per core; `965bb5d`). The workflow has not run yet; the first build's duration on the Pi is unknown. -* Script `infra/build-images.sh [tag]` on the Mac is the fallback (D-104): builds the three images for `linux/arm64`, tag defaults to the short git SHA of `HEAD`, also tags `latest`; pushes when `infra/secrets/registry.env` (git-ignored: `KVITT_REGISTRY`, optional `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN`) exists, otherwise builds locally and prints the exact push commands. Refuses to run with a dirty working tree or while a Gradle build holds the `tools/gradle-serial.sh` lock. Works with Docker buildx and with Podman. -* `server` image: multi-stage. Stage 1 `rust:-bookworm`, where `` 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. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (`infra/dev`, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (`docs/spikes/pi-image-arm64.md`, landed `1e404cc`): arm64 image 63.8 MB, cold build 48 s, CA bundle from distroless, binaries run natively; running on the Pi itself is M2c-pi's. +* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host` (an act_runner on a Raspberry Pi 3 with 1 GB RAM; the builds run on that Pi itself), `sh` steps, Alpine `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with the repository secret `kvitt_cicd` (created by the owner), a plain-HTTP buildx builder `local-http-builder` (`buildkitd.toml` with `http = true` for `gitea.lan`) created once and reused so its BuildKit cache survives, `actions/checkout@v4` with `fetch-depth: 0`. The token reaches `docker login` through the environment, never expanded inside a shell line (security review L-8, `e70fe0d`); `kvitt_cicd` needs only the `write:package` scope, because the git tag push goes through the credentials `actions/checkout` stored (the job's own token) (D-117; if that push fails, adding `write:repository` is the owner's decision). Every `buildx` push passes `--provenance=false` and `--metadata-file`, and a step "Print image digests" prints each pushed image's digest, which must match what the Pi records (D-117). Triggers: a push touching `server/**`, `infra/cert-sync/**`, `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch`. Version `v..` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is what the deploy script takes; it says nothing about the APK version (§10.6). The Pi runner passes `--build-arg CARGO_BUILD_JOBS=1` to the server build (Pi 3, 1 GB), and `server/Dockerfile` honours it (`ARG CARGO_BUILD_JOBS` in its build stage, passed to cargo as `--jobs` only when set; unset on the Mac means one job per core; `b4d9467`). The first push to `origin` (2026-10-18) triggered the first run; its result and duration are still to be reported by the owner. Unverified risk: the workflow pushes a git tag, and whether that push re-triggers the path-filtered workflow is not known. +* Script `infra/build-images.sh [tag]` on the Mac is the fallback (D-104): builds the three images for `linux/arm64`, tag defaults to the short git SHA of `HEAD`, also tags `latest`; pushes when `infra/secrets/registry.env` (git-ignored: `KVITT_REGISTRY`, optional `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN`) exists, otherwise builds locally and prints the exact push commands. Refuses to run with a dirty working tree or while a Gradle build holds the `tools/gradle-serial.sh` lock (the lock check shared through `tools/gradle-lock.sh`, `2fac1ec`). Works with Docker buildx and with Podman. +* `server` image: multi-stage. Stage 1 `rust:-bookworm`, where `` 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. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (`infra/dev`, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (`docs/spikes/pi-image-arm64.md`, landed `3e6c337`): arm64 image 63.8 MB, cold build 48 s, CA bundle from distroless, binaries run natively; running on the Pi itself is M2c-pi's. * `cert-sync` image: `alpine` with `openssh-client-default`, `curl`, `openssl` (PEM and key checks, D-104) and `tzdata`, and the shell script `infra/cert-sync/sync.sh`, run once at start by `infra/cert-sync/entrypoint.sh` and then daily by supercronic. * `backup` image: `alpine` with `sqlite`, `gzip`, `tar`, `rclone` and `tzdata`, and `infra/backup/backup.sh` run by supercronic; the healthcheck is `infra/backup/healthcheck.sh`. * Both sidecars schedule with supercronic instead of busybox `crond` (D-104): they run as non-root users, and busybox `crond` started as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedules are unchanged (`0 4 * * *` cert-sync, `30 3 * * *` backup). * Pinned versions (resolved 2026-10-18, D-104; table in `infra/README.md` "Versions"): Caddy `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`; Alpine `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` for both sidecars; cert-sync packages openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; backup packages supercronic 0.2.49 beside M2c-dev's sqlite, rclone and tar. Docker Engine on the Kvitt Pi comes from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is for 32-bit only), `docker-ce` 5:29.8.2 with `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin` and `docker-compose-plugin`. * Bundle scripts (D-103), run by the owner on the Kvitt Pi (D-100), with shared helpers in `infra/pi/lib.sh`: - * `infra/pi/install.sh`, run from the copied bundle, idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secret files with the modes and owners of §10.2 and §10.7, adds `gitea.lan` to `insecure-registries` in `/etc/docker/daemon.json` (restarting Docker when that changed) and logs in to the registry when `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` are set, copies `compose.yaml`, `Caddyfile`, `.env` and the scripts to `/opt/kvitt`, sets the volume owners, pulls, starts the stack and waits for it. A newer bundle is installed by copying it again and re-running `install.sh`. - * `/opt/kvitt/deploy.sh [--skip-backup]`: runs `backup-now.sh` first on every deploy, because the Pi cannot tell whether a tag carries a migration (skipped when no database exists yet, or with `--skip-backup`, for a rollback while Drive is down); keeps the current tag in `/opt/kvitt/previous-tag` for rollback; sets `KVITT_TAG` in `/opt/kvitt/.env`, runs `docker compose pull && docker compose up -d`, waits for `server` to be healthy, prints `GET /health`. + * `infra/pi/install.sh`, run from the copied bundle, idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secret files with the modes and owners of §10.2 and §10.7, adds `gitea.lan` to `insecure-registries` in `/etc/docker/daemon.json` (restarting Docker when that changed) and logs in to the registry when `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` are set, copies `compose.yaml`, `Caddyfile`, `.env` and the scripts to `/opt/kvitt`, sets the volume owners, pulls, starts the stack and waits for it. A newer bundle is installed by copying it again and re-running `install.sh`; a `KVITT_TAG` that `deploy.sh` set in `/opt/kvitt/.env` is kept, so re-running an older bundle never rolls the server back to the bundle's tag (`b647faf`). It records the pulled image digests as `deploy.sh` does (D-117). + * `/opt/kvitt/deploy.sh [--skip-backup]`: runs `backup-now.sh` first on every deploy, because the Pi cannot tell whether a tag carries a migration (skipped when no database exists yet, or with `--skip-backup`, for a rollback while Drive is down); keeps the current tag in `/opt/kvitt/previous-tag` for rollback; sets `KVITT_TAG` in `/opt/kvitt/.env`, runs `docker compose pull`, prints the registry digest of every image the stack runs and appends one line ` ` per image to `/opt/kvitt/deployed-digests`, printing a WARNING when a reference recorded before now has a different digest (expected only for a moving tag such as `latest`; the registry is plain HTTP on the LAN, so a swapped image would otherwise go unnoticed, security review L-9, D-117), then `docker compose up -d`, waits for `server` to be healthy, prints `GET /health`. * `/opt/kvitt/bootstrap.sh` (first install: the admin invite of FR-1), `/opt/kvitt/backup-now.sh` (one backup run, succeeds only when `last-success` changed, §10.9), `/opt/kvitt/install-apk.sh` (§10.6). * The Pi's Docker daemon must trust `gitea.lan` as an insecure (plain HTTP) registry (`insecure-registries`). `install.sh` adds it with sudo, run by the owner (D-103). @@ -939,6 +945,7 @@ Files under `KVITT_DATA_DIR`: | `serve` | Run migrations, start scheduler, push sender and HTTP server | 0 on clean shutdown (SIGTERM drains in-flight requests up to 10 s) | | `bootstrap` | FR-1 | 0 ok, 2 already initialized | | `pair-device --user ` | AC-1.6 | 0 ok, 3 unknown user | +| `invite-partner` | Creates a partner invite by the admin, as E-12 (D-113) | 0 ok, 2 household full, 3 no admin | | `rotate-epoch` | Sets a new random `sync_epoch` and prints it on stdout (§10.9) | 0 ok, 1 database error | | `healthcheck` | Calls `http://127.0.0.1:/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 | @@ -951,18 +958,27 @@ Run them on the Pi with `docker compose exec server kvitt-server ` (or * The Home Assistant Pi renews the Let's Encrypt certificate for `icehack.asuscomm.com` (its own certbot; Kvitt does not touch it). * `cert-sync` runs `sync.sh` at container start and daily at 04:00 Europe/Stockholm. It connects to `${HA_USER}@${HA_HOST}:${HA_PORT}` with the key `/secrets/ha_ssh_key` and `StrictHostKeyChecking=yes` against `/secrets/known_hosts`, always requests the remote command `tar -C ${HA_CERT_DIR} -cf - fullchain.pem privkey.pem` and unpacks the tar stream into a temp directory (D-106). -* The key is dedicated to Kvitt (`infra/secrets/ha_ssh_key` on the Mac, git-ignored). Where the Home Assistant SSH add-on allows `authorized_keys` options it is restricted with `restrict,command="tar -C -cf - fullchain.pem privkey.pem"` (variant A); otherwise it is added as a plain key (variant B). Because the script always requests that exact command, both variants work unchanged (D-106). Which variant the add-on accepts is reported by the owner at checklist step 3 and recorded in `infra/README.md`. +* The key is dedicated to Kvitt (`infra/secrets/ha_ssh_key` on the Mac, git-ignored). It is added restricted with `restrict,command="tar -C -cf - fullchain.pem privkey.pem"` (variant A, required; optionally `from=""` too). A plain key for `root` (the old variant B) is not acceptable (D-117, security review M-2): the add-ons log in as root, and the key would give an unrestricted root shell on the Home Assistant Pi. If the add-on refuses the restricted line, a dedicated non-root user that can only read `HA_CERT_DIR` (`HA_USER`) gets the plain key; if neither is possible, the fallback is §10.5.3. Because the script always requests that exact command, both forms work unchanged (D-106). Which form the add-on accepts is reported by the owner at checklist step 3 and recorded in `infra/README.md`. * If both files parse as PEM, the certificate's not-after is in the future, the private key matches the certificate, the certificate does not expire before the installed one, and the SHA-256 of either differs from `/certs`, the script moves them into `/certs` (mode 0640, group readable by Caddy) and reloads Caddy: `curl -fsS -X POST http://caddy:2019/load -H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @/etc/caddy/Caddyfile`. A key not matching its certificate, or a certificate expiring before the installed one, is refused and the installed pair is kept (D-106). At first start Caddy is not up yet (it waits for `cert-sync` to be healthy) and loads the files when it starts, so an unreachable Caddy admin API is logged and is not a failure (D-106). * Any failure logs one line and keeps the existing certificate. The healthcheck stays green while valid files exist. #### 10.5.2 Caddyfile -File `infra/pi/Caddyfile` (D-103), installed as `/opt/kvitt/Caddyfile`: +File `infra/pi/Caddyfile` (D-103), installed as `/opt/kvitt/Caddyfile`. As of `7fb86f5` (D-117) it reads: ``` +# Kvitt Caddyfile (SPEC §10.5.2, D-117). Installed as /opt/kvitt/Caddyfile; cert-sync reloads it through the admin API. { admin 0.0.0.0:2019 auto_https off + # Caddy's own error log (for example a 502 when the server is down) carries the request too: same URI filter. + log default { + output stderr + format filter { + request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED` + wrap json + } + } } icehack.asuscomm.com:8443 { @@ -970,15 +986,29 @@ icehack.asuscomm.com:8443 { request_body { max_size 6MB } + # Set after the server's response headers (defer), so a header the server also sends appears once. + header { + defer + Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy no-referrer + } reverse_proxy server:8080 + # Access log: the invite code in /i/ and every query string (the inviter's name in ?from=) become + # REDACTED (SPEC §8.7); the Authorization header is redacted by Caddy's default. log { output stdout - format json + format filter { + request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED` + wrap json + } } } ``` -Caddy logs must not include the `Authorization` header (Caddy redacts it by default). Verified on the Mac on 2026-10-18 with the pinned image: the header is logged as `"Authorization":["REDACTED"]` and the token never appears; TLS 1.1 is refused. +* Logs (D-117, security review L-1): the access log and Caddy's own error log (`log default`, which also carries the request, for example on a 502) pass `request>uri` through a regexp filter that turns `/i/` into `/i/REDACTED` and every query string into `?REDACTED`, so neither invite codes nor the inviter's name (`?from=`) reach a log (§8.7). Caddy logs must not include the `Authorization` header (Caddy redacts it by default). Verified on the Mac on 2026-10-18 with the pinned image: the header is logged as `"Authorization":["REDACTED"]` and the token never appears; TLS 1.1 is refused. +* Response headers (D-117): `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options` and `Referrer-Policy` as above, set with `defer` (after the server's own headers), so a header the server also sends (the landing page's `Referrer-Policy: no-referrer`, AC-2.5) appears once. #### 10.5.3 Fallback: Caddy internal CA @@ -999,7 +1029,7 @@ Used only if the certificate sync cannot be made to work. Documented in `infra/R ``` The file is git-ignored by the `*.jks` pattern and never committed (D-101, revises D-024's "outside the repository"). `android/local.properties` holds its absolute path and secrets in the keys `kvitt.release.storeFile`, `kvitt.release.storePassword`, `kvitt.release.keyAlias` (`kvitt`), `kvitt.release.keyPassword`. Losing it means the phones must uninstall and reinstall (data comes back by sync). The owner keeps a copy of the file and its password in their password manager. Without these keys in `local.properties`, `:app:assembleRelease` builds an unsigned APK (M8). Release signing was verified on 2026-10-18: alias `kvitt`, RSA 4096, APK signature scheme v2. -* Release builds are R8-shrunk, optimised and obfuscated (`isMinifyEnabled`, `isShrinkResources`, `proguard-android-optimize.txt` plus `android/app/proguard-rules.pro`, no project keep rules needed as of 2026-10-18; landed `57f984f`); the signed APK is about 11 MB (was 43 MB); `infra/publish-apk.sh` saves `mapping.txt` next to the APK as `kvitt-.mapping.txt` for retrace; mappings are never served and stay outside the `data` volume (D-103). +* Release builds are R8-shrunk, optimised and obfuscated (`isMinifyEnabled`, `isShrinkResources`, `proguard-android-optimize.txt` plus `android/app/proguard-rules.pro`, no project keep rules needed as of 2026-10-18; landed `c30a49e`); the signed APK is about 10 MB (10.0 MB at `a0259ab`; 43 MB before R8); `infra/publish-apk.sh` saves `mapping.txt` next to the APK as `kvitt-.mapping.txt` for retrace; mappings are never served and stay outside the `data` volume (D-103). * `infra/publish-apk.sh` on the Mac (D-103): refuses a dirty working tree, builds `:app:assembleRelease` through `tools/gradle-serial.sh` with `-Pkvitt.versionCode` and `-Pkvitt.versionName`, refuses an unsigned APK, computes SHA-256 and size, and writes into `infra/pi/out/apk/` (git-ignored) `kvitt-.apk`, `kvitt-.mapping.txt` and `latest.json` (written to a temp file, then renamed), keeping the newest 5 APKs with their mappings there. The owner copies the folder to `/opt/kvitt/apk/` on the Pi (`scp`) and runs `/opt/kvitt/install-apk.sh`, which checks each SHA-256 against `latest.json`, copies the APKs into the `data` volume as `apk/kvitt-.apk` and writes `apk/latest.json` last (temp file, rename), so the server never announces a file it does not have; the mapping files stay in `/opt/kvitt/apk/`. `latest.json`: ```json @@ -1117,11 +1147,13 @@ Restore (documented step by step in `infra/RESTORE.md`; its commands run through ### 10.10 Operations * Logs: `docker compose logs `. -* Disk: SQLite is a few MB; pictures under 1 MB; APKs about 11 MB each since R8 (§10.6), 5 kept. +* Disk: SQLite is a few MB; pictures under 1 MB; APKs about 10 MB each since R8 (§10.6), 5 kept. * Upgrades: the Gitea workflow builds and pushes a `v1.0.N` tag (or `infra/build-images.sh` on the Mac), then the owner runs `/opt/kvitt/deploy.sh ` on the Pi. Migrations run on start; a failed migration exits non-zero, the container restarts in a loop, and the previous tag is redeployed with `/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup` (migrations are forward-only, so the infra plan's runbook says to restore the pre-upgrade backup if a migration had partially applied; sqlx runs each migration in a transaction, so this should not happen). * Before every deploy, `deploy.sh` runs a backup and waits for `last-success` to update: the rule "back up before a deploy with a migration" is applied to every deploy, because the Pi cannot tell beforehand whether a tag carries one (D-103). * Who runs what (D-100): every command on the Pi in this section is run by the owner from the M2c-pi bundle's scripts and README checklist; the deploy script is `infra/pi/deploy.sh`, installed as `/opt/kvitt/deploy.sh` (D-103); the owner's operations table is in `infra/README.md`. Agents prepare and check the bundle on the Mac and never connect to the Pi. * Memory check: `docker stats --no-stream` after the release milestone; server RSS must be under 100 MB with both phones syncing. +* Image digests: `/opt/kvitt/deployed-digests` holds one line per image and deploy (§10.3, D-117); a WARNING from `deploy.sh` or `install.sh` that a tag changed digest is investigated before the stack is trusted. +* Client IPs (once, after the router forward, D-117): with the phone off Wi-Fi, open `/health`, then check that Caddy's access log shows the mobile network's public address in `remote_ip`, not a `172.*` or `10.*` bridge address; otherwise every internet client shares one per-IP limit of §8.6 (security review I-5; the command is in `infra/README.md`'s operations table). --- @@ -1146,63 +1178,70 @@ M0 spec ──┬─► U1 directions ─(owner picks)─► U2 design spec ─ M11 import real history ◄── M9, M10 Category contract v2 (D-054), after SPEC v0.8: -M12a contracts + :domain suggestCategory (landed 6b60ef7) ─┬─► M12b server (migration 0002, importer table; landed 2e2ebe7) - └─► M12c android (:core:model, :domain, :data, fakes; landed e499107) ─► M6, M7c -M12d UI contract additions (D-059: frequentCategoryIds, deletable; landed bdba9d3) ─► M7a..M7f (ran in parallel with M6, landed 3080804) +M12a contracts + :domain suggestCategory (landed aa765bb) ─┬─► M12b server (migration 0002, importer table; landed 6eaccec) + └─► M12c android (:core:model, :domain, :data, fakes; landed b09f322) ─► M6, M7c +M12d UI contract additions (D-059: frequentCategoryIds, deletable; landed 557af7e) ─► M7a..M7f (ran in parallel with M6, landed 7730a31) After the M7 wave (v0.11): -M7a..M7f (landed) ─► M6b core:design fixes (landed 3a1bd14) ─┬─► M8 app integration (landed 00a14a3) - └─► M7g feature cleanups (landed 34a2590) +M7a..M7f (landed) ─► M6b core:design fixes (landed b24c8b5) ─┬─► M8 app integration (landed 3d9eea8) + └─► M7g feature cleanups (landed 3dda414) After M8 (v0.13): -M8, M7g (landed) ─► M12e contract pass (landed 1470a2f and 90505a8; D-086 to D-095, server schema 3) +M8, M7g (landed) ─► M12e contract pass (landed 8c60715 and b7b7483; D-086 to D-095, server schema 3) After the first phone test (v0.15): -M8 phone test (2026-10-18) ─► M8b UI fixes (landed f0575d3, D-097) ─► M2c-pi bundle (D-100), last, after the app is proven ─► M10 +M8 phone test (2026-10-18) ─► M8b UI fixes (landed 328c466, D-097) ─► M2c-pi bundle (D-100), last, after the app is proven ─► M10 After v0.15 (v0.16): -M8b ─► M8c goldens and About (landed b481fab, 64f5706, 7c453d8; D-098, D-099) ─► M8d editor rows (landed 3c68594, D-102) ─► M8e (D-107) -R8 release build (landed 57f984f) +M8b ─► M8c goldens and About (landed 75b549f, a0c25b0, 7c453d8; D-098, D-099) ─► M8d editor rows (landed bacbd18, D-102) ─► M8e (D-107) +R8 release build (landed c30a49e) After v0.16 (v0.17): -M2c-pi bundle (landed 5e027df, D-103 to D-106) ─► owner runs the bundle on the Kvitt Pi ─► M10 -M8e S-19 option rows (landed 15397e4, D-107) ─► chips cleanup (landed 4755fe0) -M8f instant navigation (landed ed1b130, D-108) +M2c-pi bundle (landed 2476667, D-103 to D-106) ─► owner runs the bundle on the Kvitt Pi ─► M10 +M8e S-19 option rows (landed a59abeb, D-107) ─► chips cleanup (landed 1328afe) +M8f instant navigation (landed 956908a, D-108) + +After v0.17 (v0.18): +history rewrite and first push (18c527f) ─► infra overhaul (2fac1ec..e70fe0d) ─► server gap pass (landed 8660b7f, D-113) +security review (18c527f) ─┬─► infra security (landed 7fb86f5, D-117) +AC-coverage audit (956908a) ─┼─► M12f Android follow-ups (landed dc7a296..a0259ab, D-109 to D-112, D-116) + └─► server overhaul (landed 8103dae..b985ce9; D-114, D-115) ─► router forward ─► M10 ``` -After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001), as the last step once the app is proven on the phone against the development server (D-100). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: `6b60ef7`, `2e2ebe7`, `e499107`). M12d (D-059) ran in parallel with M6 (landed `bdba9d3` and `3080804`); M7a to M7f ran in parallel and landed on 2026-10-18 (`b9b061a`, `31760f9`, `1e42f76`, `082b8dc`, `6ac711d`, `a54eb7c`), followed by M6b, the `:core:design` fix pass (`3a1bd14`). M8 and M7g (feature cleanups) ran in parallel (disjoint paths: `android/app/**` and `android/adapter/fcm/**` against `android/feature/**`) and landed on 2026-10-18 (`34a2590`; `369e516` and `00a14a3`). The contract pass M12e (D-086 to D-095) ran after them and landed on 2026-10-18 (`1470a2f` contract pass, `90505a8` launcher icon). The M2c-pi bundle (`5e027df`, D-103 to D-106), M8e (`15397e4`, D-107) and M8f (`ed1b130`, D-108) landed on 2026-10-18; what remains of M2c-pi is the owner running the bundle, and M10 waits for it. +After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001), as the last step once the app is proven on the phone against the development server (D-100). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: `aa765bb`, `6eaccec`, `b09f322`). M12d (D-059) ran in parallel with M6 (landed `557af7e` and `7730a31`); M7a to M7f ran in parallel and landed on 2026-10-18 (`1ea752c`, `0e1d99d`, `0b140aa`, `7380e50`, `ea7d494`, `c4ab35f`), followed by M6b, the `:core:design` fix pass (`b24c8b5`). M8 and M7g (feature cleanups) ran in parallel (disjoint paths: `android/app/**` and `android/adapter/fcm/**` against `android/feature/**`) and landed on 2026-10-18 (`3dda414`; `cb832a7` and `3d9eea8`). The contract pass M12e (D-086 to D-095) ran after them and landed on 2026-10-18 (`8c60715` contract pass, `b7b7483` launcher icon). The M2c-pi bundle (`2476667`, D-103 to D-106), M8e (`a59abeb`, D-107) and M8f (`956908a`, D-108) landed on 2026-10-18; what remains of M2c-pi is the owner running the bundle, and M10 waits for it. ### 11.2 Milestone table | Id | Name | Deliverable | Gate | HUMAN | Depends on | |---|---|---|---|---|---| | M0 | Spec v0.1 | `SPEC.md`, filled `CLAUDE.md`, repository bootstrap | none (docs only) | Review the spec; answer §12 | none | -| U1 | UX directions | Landed `16c4d01` (direction 3 "Kalas" muted picked, D-047). §13.4 phase 1 files in `docs/ux/` | Files exist and open; each direction shows the six phase-1 screens of §13.4 (S-4, S-7, S-5, S-9, S-11, S-8) in light and dark; Swedish copy, no placeholder text | Review 2-3 directions, pick one, give comments | M0 | -| U2 | UX design spec | Landed `16c4d01`, approved by the owner: `docs/ux/design-spec.md` (binding, §13.4), `strings.csv` (558 rows), `screens/`; UX-9 (D-059). §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 | +| U1 | UX directions | Landed `6da6b1b` (direction 3 "Kalas" muted picked, D-047). §13.4 phase 1 files in `docs/ux/` | Files exist and open; each direction shows the six phase-1 screens of §13.4 (S-4, S-7, S-5, S-9, S-11, S-8) in light and dark; Swedish copy, no placeholder text | Review 2-3 directions, pick one, give comments | M0 | +| U2 | UX design spec | Landed `6da6b1b`, approved by the owner: `docs/ux/design-spec.md` (binding, §13.4), `strings.csv` (558 rows), `screens/`; UX-9 (D-059). §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 | | 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, 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-pi | Infra on the Pi (D-001, D-100) | Bundle landed `5e027df` 2026-10-18 (D-103 to D-106); the owner's run on the Pi remains. A self-contained bundle in `infra/pi/` that the owner copies to the Kvitt Pi and runs (D-100): compose stack, Caddyfile, env template with the OQ-3 values as placeholders, cert-sync image, install, deploy, bootstrap and backup scripts, a README checklist for the owner (Pi provisioning, router forwarding, trust of the `gitea.lan` registry, the cert-sync key on the Home Assistant SSH add-on, `rclone authorize "drive"`), `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) | On the Mac: `docker compose -f infra/pi/compose.yaml config` succeeds with the env template (D-103); `shellcheck $(find infra -name '*.sh')` clean (green at landing: shellcheck clean on 18 scripts, compose config valid, the cert-sync arm64 image builds; rehearsed on the Mac with Podman: cert-sync install, renew, skip and refuse cases, Caddy reload through the admin API, `Authorization` redacted in Caddy's log, TLS 1.1 refused, backup upload with `rclone lsf` confirmation and deletion after confirmation, retry after a failed upload, backup healthcheck red at 26 h, APK prune keeps 5; not verifiable from the Mac: the Home Assistant add-on's `authorized_keys` options, the Gitea workflow run, pulls from `gitea.lan`, the router forward, Drive, real Docker compose). On the Pi, run by the owner from the checklist: 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 | Copy the bundle to the Kvitt Pi and fill the placeholders in its `.env` (OQ-3); run the README checklist: install Docker if missing, add `gitea.lan` to `insecure-registries`, set the router forward 8443 → Kvitt Pi, add the cert-sync public key to the Home Assistant SSH add-on, `rclone authorize "drive"`, provide the example Gitea Actions workflow and the Gitea credentials; answer yes/no per check. Done by 2026-10-18: the Drive token (`rclone.conf`), the example workflow, the Gitea secret `kvitt_cicd`, the cert-sync key `infra/secrets/ha_ssh_key`. Remaining: the owner runs the bundle, the nine steps of `infra/README.md` "Pi: what the owner does" (provision, Docker, the add-on key variant A or B, `.env` and secrets, copy and host key, `install.sh`, router forward, first backup and bootstrap, deploy). No allowances for agents on the Pis, Home Assistant, Gitea or Drive (D-100) | M2c-dev, M8; runs last, after the app is proven (D-100) | -| M3 | Server core API | Landed `503cee5` (D-030 to D-034). 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 | Landed `bd6f10a` (D-035 to D-039). 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 | Landed `c28c52b` (D-026 to D-029, D-040 to D-043). 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 | -| M12a | Category contract v2: contracts | Landed `6b60ef7`. `contracts/category-keywords.csv` (148 entries), `contracts/schema/migrations/0002_categories_v2.sql` (§14.1.1), `contracts/test-vectors/suggest_category.json` (65 cases), E-1 example `schema_version` 2, `tools/contracts-check.py` (keyword-table rules, 0002 loaded on a fresh and an in-use v1 database, Python reference of §9.11, new icon and colour tokens), and `:domain`'s `suggestCategory` with `UnicodeText` passing the vectors (§14.5.6). The OpenAPI `DefaultCategoryKey` extension and the new category examples were held back for M12b | `python3 tools/contracts-check.py` passes | none | M1, SPEC v0.8 | -| M12b | Category contract v2: server | Landed `2e2ebe7` (D-057, D-058). Migration `server/crates/kvitt-server/migrations/0002_categories_v2.sql` byte-identical to the contract file with its identity test; `SCHEMA_VERSION` 2; seed test of the fourteen defaults (ids, keys, icons, colours, sort orders, `server_version` from the sequence) on a fresh database and on a v1 database holding expenses (rows and references kept); icon and colour lists of §9.7 in `validate.rs`; `DefaultCategoryKey` with the fourteen keys in `contracts/openapi.yaml` (held back by M12a) and in the utoipa wire types, so the OpenAPI match test passes; the category examples M12a held back; importer table `splitwise_categories.csv` per §10.8 with its spec-parity test; importer schema check 2 (D-054); E-31 and other examples only where they enumerate the defaults | Server gate; `python3 tools/contracts-check.py` passes | none | M12a, M3, M4, M9 | -| M12c | Category contract v2: Android | Landed `e499107` (D-055 to D-057). `:core:model`: `DefaultCategoryKey` (14), `ColorToken` (`SLATE`, `AMBER`), `CategoryIcons.ALL` (33), `ExpenseRepository.suggestCategory`; `:data`: the `UnicodeText` binding (`java.text.Normalizer`, `Character`), `suggestCategory` from Room, DataStore and the keyword table copied from `contracts/category-keywords.csv` at build time (D-055; a test asserts byte identity and the §14.8 rules), parsed by `:domain`'s `parseCategoryKeywords` (D-056); `:core:fakes`: fourteen defaults in the empty scenario and a fake `suggestCategory`; default-name string resources for the four new categories in English and Swedish (AC-7.2) where default names already live | Android gate | none | M12a, M2b, M5 | -| M12d | UI contract additions (D-059) | Landed `bdba9d3` (D-060 to D-062). `:core:model`: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable(id)` (§14.2.4 "Changes in v0.9"); `:data`: both computed from Room, with repository tests; `:core:fakes`: both computed from each scenario's own data; `ContractTest` updated | Android gate | none | M12c, M5 | -| M6 | Design system | Landed `3080804` (D-063 to D-066). Built from `docs/ux/design-spec.md` §2 and §3 and `docs/ux/strings.csv`. `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts (Compose `Canvas`, D-063), 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, M12c, the §9.7 colour change | -| M6b | Design-system fixes after M7 | Landed `3a1bd14` (D-075 to D-079). `:core:design` fixes the M7 agents asked for: `viewerPercentOf` reads basis points, `editor_split_full_on_you`, `AmountInput.of` with both decimals and `AmountInput.percent` (D-078), `quickRowCategories` (D-075), two-line category labels with an ellipsis (D-076), `DateFormatter.monthName` and `dayRangeLong` (D-077), resource-backed snackbars in `KvittScaffold`, optional `ConfirmDialog` body, `KvittButton` loading form, `InviteCodeField` wrapping, `EmptyState` icon tile, `AnalyticsFilterBar` period row, screen tokens in `KvittDimens`, `common_ok` and `common_undo`, the module keys of M7b to M7f recorded in `docs/ux/strings.csv` (D-079) | Android gate | none | M7a to M7f | -| M7a | Feature: onboarding | Landed `b9b061a` 2026-10-18 (D-071). `: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, M12d | -| M7b | Feature: home and account | Landed `31760f9` 2026-10-18 (D-071). `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6, M12d | -| M7c | Feature: expense | Landed `1e42f76` 2026-10-18 (D-074, D-080). `:feature:expense` screens S-7, S-8, with the category suggestion of §9.11 (CS-2, CS-3, CS-7) | as M7a | none | M6, M12c, M12d | -| M7d | Feature: settle | Landed `082b8dc` 2026-10-18 (D-074). `:feature:settle` screens S-9, S-10 | as M7a | none | M6, M12d | -| M7e | Feature: analytics | Landed `6ac711d` 2026-10-18 (D-069 to D-071). `:feature:analytics` screens S-11, S-12 | as M7a | none | M6, M12d | -| M7f | Feature: settings | Landed `a54eb7c` 2026-10-18 (D-071 to D-073). `:feature:settings` screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 | as M7a | none | M6, M12d | -| M7g | Feature cleanups | Landed `34a2590` 2026-10-18. Cleanups in `android/feature/**` after M6b; scope in its brief | as M7a | none | M7a to M7f, M6b | -| M8 | App integration | Landed `369e516` and `00a14a3` 2026-10-18 (D-081 to D-085). `:app` navigation and deep links (§13.2, D-071; each top-level screen draws its own bar), session gate (S-1 for Unpaired and Unrecognised, S-2, S-18 for Revoked, back on S-18 closes the app; Home shows S-3 while `partner == null`), deletion of the debug fakes module (both variants bind `:data`, D-081), `:adapter:fcm` with registration by Firebase Installation ID (D-083), push work and notification building (AC-13.5, D-085), invite links ignored while paired (D-084), `ProvideHousehold` as `LocalKvittHousehold`, `LocalKvittClock` from `:data`'s Clock, `LocalAvatarPictures` handing the `User` to `:data`'s Coil mapper, notification permission after the first successful sync after pairing (AC-2.9), update check in release only, revoke wipe, network security configs (§8.5, D-082), manifest hardening (§8.8), release signing and version properties (§10.6; unsigned without keystore keys), `google-services.json` copied with `infra/dev/copy-google-services.sh` | Android gate (green at landing: BUILD SUCCESSFUL, 710 actionable tasks); on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced, receive a push (run on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device`; Home showed the account; the offline expense showed the pending marker and synced after reconnect, pending 0, row on the server; the device registered `fcm_token` and `app_version_name`; a push by `fid` arrived and its tap worked). Follow-up M8b (landed `f0575d3`, D-097) fixed what the test showed: the invite-code field, the quick-row badge, the status-bar inset of `KvittTopAppBar`, the chart corners; `FcmMessageBody.fid` in `:data` followed (`09f474f`, D-096) | Tap through the pairing, offline and push test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) | -| M12e | Contract pass | Landed `1470a2f` and `90505a8` 2026-10-18 (D-086 to D-095). The §11.3 contract requests of M5 to M8 taken together on both sides: E-3 `invite_kind` and `Pairing` (D-086); the admin's name in E-12 links, passed on by E-4 (D-087); `SyncSummary.lastAttemptFailed` (D-088); `Rejection.actor` (D-089); `X-Kvitt-App-Version-Name`, `devices.app_version_name` by migration 0003, server schema 3 (D-090); `NewRecurringTemplate` (D-091); `UpdateCheck` (D-092); `SettleState.untouched` (D-093); the D-027 re-stamp in `:data` (D-094); `PushEvent` in `:core:model`, snackbar hosts in six screens, snackbar resource ids from S-7 and S-8, the adaptive launcher icon (D-095). Goldens re-recorded: S-20 devices (version name); new `s1_pairing_link_admin` and `s22_about_check_failed` | Both gates (green at landing: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; server 253 tests, the OpenAPI equality, vector and migration byte-identity tests included) | none (the development server must be rebuilt before the new APK is installed, §14.8) | M8, M7g, SPEC v0.12 | -| M9 | Splitwise importer | Landed `ab298ab` (D-044 to D-046). `kvitt-import` per §10.8 with tests over synthetic CSV fixtures (`crates/kvitt-import/tests/import.rs`), reconciliation against the totals rows, the committed category table | Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 | +| M2c-pi | Infra on the Pi (D-001, D-100) | Bundle landed `2476667` 2026-10-18 (D-103 to D-106); the owner's run on the Pi remains. A self-contained bundle in `infra/pi/` that the owner copies to the Kvitt Pi and runs (D-100): compose stack, Caddyfile, env template with the OQ-3 values as placeholders, cert-sync image, install, deploy, bootstrap and backup scripts, a README checklist for the owner (Pi provisioning, router forwarding, trust of the `gitea.lan` registry, the cert-sync key on the Home Assistant SSH add-on, `rclone authorize "drive"`), `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) | On the Mac: `docker compose -f infra/pi/compose.yaml config` succeeds with the env template (D-103); `shellcheck $(find infra -name '*.sh')` clean (green at landing: shellcheck clean on 18 scripts, compose config valid, the cert-sync arm64 image builds; rehearsed on the Mac with Podman: cert-sync install, renew, skip and refuse cases, Caddy reload through the admin API, `Authorization` redacted in Caddy's log, TLS 1.1 refused, backup upload with `rclone lsf` confirmation and deletion after confirmation, retry after a failed upload, backup healthcheck red at 26 h, APK prune keeps 5; not verifiable from the Mac: the Home Assistant add-on's `authorized_keys` options, the Gitea workflow run, pulls from `gitea.lan`, the router forward, Drive, real Docker compose). On the Pi, run by the owner from the checklist: 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 | Copy the bundle to the Kvitt Pi and fill the placeholders in its `.env` (OQ-3); run the README checklist: install Docker if missing, add `gitea.lan` to `insecure-registries`, set the router forward 8443 → Kvitt Pi, add the cert-sync public key to the Home Assistant SSH add-on, `rclone authorize "drive"`, provide the example Gitea Actions workflow and the Gitea credentials; answer yes/no per check. Done by 2026-10-18: the Drive token (`rclone.conf`), the example workflow, the Gitea secret `kvitt_cicd`, the cert-sync key `infra/secrets/ha_ssh_key`. Remaining: the owner runs the bundle, the nine steps of `infra/README.md` "Pi: what the owner does" (provision, Docker, the add-on key variant A or B, `.env` and secrets, copy and host key, `install.sh`, router forward, first backup and bootstrap, deploy). No allowances for agents on the Pis, Home Assistant, Gitea or Drive (D-100) | M2c-dev, M8; runs last, after the app is proven (D-100) | +| M3 | Server core API | Landed `7869ec4` (D-030 to D-034). 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 | Landed `15056dc` (D-035 to D-039). 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 | Landed `8f0e156` (D-026 to D-029, D-040 to D-043). 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 | +| M12a | Category contract v2: contracts | Landed `aa765bb`. `contracts/category-keywords.csv` (148 entries), `contracts/schema/migrations/0002_categories_v2.sql` (§14.1.1), `contracts/test-vectors/suggest_category.json` (65 cases), E-1 example `schema_version` 2, `tools/contracts-check.py` (keyword-table rules, 0002 loaded on a fresh and an in-use v1 database, Python reference of §9.11, new icon and colour tokens), and `:domain`'s `suggestCategory` with `UnicodeText` passing the vectors (§14.5.6). The OpenAPI `DefaultCategoryKey` extension and the new category examples were held back for M12b | `python3 tools/contracts-check.py` passes | none | M1, SPEC v0.8 | +| M12b | Category contract v2: server | Landed `6eaccec` (D-057, D-058). Migration `server/crates/kvitt-server/migrations/0002_categories_v2.sql` byte-identical to the contract file with its identity test; `SCHEMA_VERSION` 2; seed test of the fourteen defaults (ids, keys, icons, colours, sort orders, `server_version` from the sequence) on a fresh database and on a v1 database holding expenses (rows and references kept); icon and colour lists of §9.7 in `validate.rs`; `DefaultCategoryKey` with the fourteen keys in `contracts/openapi.yaml` (held back by M12a) and in the utoipa wire types, so the OpenAPI match test passes; the category examples M12a held back; importer table `splitwise_categories.csv` per §10.8 with its spec-parity test; importer schema check 2 (D-054); E-31 and other examples only where they enumerate the defaults | Server gate; `python3 tools/contracts-check.py` passes | none | M12a, M3, M4, M9 | +| M12c | Category contract v2: Android | Landed `b09f322` (D-055 to D-057). `:core:model`: `DefaultCategoryKey` (14), `ColorToken` (`SLATE`, `AMBER`), `CategoryIcons.ALL` (33), `ExpenseRepository.suggestCategory`; `:data`: the `UnicodeText` binding (`java.text.Normalizer`, `Character`), `suggestCategory` from Room, DataStore and the keyword table copied from `contracts/category-keywords.csv` at build time (D-055; a test asserts byte identity and the §14.8 rules), parsed by `:domain`'s `parseCategoryKeywords` (D-056); `:core:fakes`: fourteen defaults in the empty scenario and a fake `suggestCategory`; default-name string resources for the four new categories in English and Swedish (AC-7.2) where default names already live | Android gate | none | M12a, M2b, M5 | +| M12d | UI contract additions (D-059) | Landed `557af7e` (D-060 to D-062). `:core:model`: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable(id)` (§14.2.4 "Changes in v0.9"); `:data`: both computed from Room, with repository tests; `:core:fakes`: both computed from each scenario's own data; `ContractTest` updated | Android gate | none | M12c, M5 | +| M6 | Design system | Landed `7730a31` (D-063 to D-066). Built from `docs/ux/design-spec.md` §2 and §3 and `docs/ux/strings.csv`. `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts (Compose `Canvas`, D-063), 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, M12c, the §9.7 colour change | +| M6b | Design-system fixes after M7 | Landed `b24c8b5` (D-075 to D-079). `:core:design` fixes the M7 agents asked for: `viewerPercentOf` reads basis points, `editor_split_full_on_you`, `AmountInput.of` with both decimals and `AmountInput.percent` (D-078), `quickRowCategories` (D-075), two-line category labels with an ellipsis (D-076), `DateFormatter.monthName` and `dayRangeLong` (D-077), resource-backed snackbars in `KvittScaffold`, optional `ConfirmDialog` body, `KvittButton` loading form, `InviteCodeField` wrapping, `EmptyState` icon tile, `AnalyticsFilterBar` period row, screen tokens in `KvittDimens`, `common_ok` and `common_undo`, the module keys of M7b to M7f recorded in `docs/ux/strings.csv` (D-079) | Android gate | none | M7a to M7f | +| M7a | Feature: onboarding | Landed `1ea752c` 2026-10-18 (D-071). `: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, M12d | +| M7b | Feature: home and account | Landed `0e1d99d` 2026-10-18 (D-071). `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6, M12d | +| M7c | Feature: expense | Landed `0b140aa` 2026-10-18 (D-074, D-080). `:feature:expense` screens S-7, S-8, with the category suggestion of §9.11 (CS-2, CS-3, CS-7) | as M7a | none | M6, M12c, M12d | +| M7d | Feature: settle | Landed `7380e50` 2026-10-18 (D-074). `:feature:settle` screens S-9, S-10 | as M7a | none | M6, M12d | +| M7e | Feature: analytics | Landed `ea7d494` 2026-10-18 (D-069 to D-071). `:feature:analytics` screens S-11, S-12 | as M7a | none | M6, M12d | +| M7f | Feature: settings | Landed `c4ab35f` 2026-10-18 (D-071 to D-073). `:feature:settings` screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 | as M7a | none | M6, M12d | +| M7g | Feature cleanups | Landed `3dda414` 2026-10-18. Cleanups in `android/feature/**` after M6b; scope in its brief | as M7a | none | M7a to M7f, M6b | +| M8 | App integration | Landed `cb832a7` and `3d9eea8` 2026-10-18 (D-081 to D-085). `:app` navigation and deep links (§13.2, D-071; each top-level screen draws its own bar), session gate (S-1 for Unpaired and Unrecognised, S-2, S-18 for Revoked, back on S-18 closes the app; Home shows S-3 while `partner == null`), deletion of the debug fakes module (both variants bind `:data`, D-081), `:adapter:fcm` with registration by Firebase Installation ID (D-083), push work and notification building (AC-13.5, D-085), invite links ignored while paired (D-084), `ProvideHousehold` as `LocalKvittHousehold`, `LocalKvittClock` from `:data`'s Clock, `LocalAvatarPictures` handing the `User` to `:data`'s Coil mapper, notification permission after the first successful sync after pairing (AC-2.9), update check in release only, revoke wipe, network security configs (§8.5, D-082), manifest hardening (§8.8), release signing and version properties (§10.6; unsigned without keystore keys), `google-services.json` copied with `infra/dev/copy-google-services.sh` | Android gate (green at landing: BUILD SUCCESSFUL, 710 actionable tasks); on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced, receive a push (run on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device`; Home showed the account; the offline expense showed the pending marker and synced after reconnect, pending 0, row on the server; the device registered `fcm_token` and `app_version_name`; a push by `fid` arrived and its tap worked). Follow-up M8b (landed `328c466`, D-097) fixed what the test showed: the invite-code field, the quick-row badge, the status-bar inset of `KvittTopAppBar`, the chart corners; `FcmMessageBody.fid` in `:data` followed (`6eb3f85`, D-096) | Tap through the pairing, offline and push test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) | +| M12e | Contract pass | Landed `8c60715` and `b7b7483` 2026-10-18 (D-086 to D-095). The §11.3 contract requests of M5 to M8 taken together on both sides: E-3 `invite_kind` and `Pairing` (D-086); the admin's name in E-12 links, passed on by E-4 (D-087); `SyncSummary.lastAttemptFailed` (D-088); `Rejection.actor` (D-089); `X-Kvitt-App-Version-Name`, `devices.app_version_name` by migration 0003, server schema 3 (D-090); `NewRecurringTemplate` (D-091); `UpdateCheck` (D-092); `SettleState.untouched` (D-093); the D-027 re-stamp in `:data` (D-094); `PushEvent` in `:core:model`, snackbar hosts in six screens, snackbar resource ids from S-7 and S-8, the adaptive launcher icon (D-095). Goldens re-recorded: S-20 devices (version name); new `s1_pairing_link_admin` and `s22_about_check_failed` | Both gates (green at landing: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; server 253 tests, the OpenAPI equality, vector and migration byte-identity tests included) | none (the development server must be rebuilt before the new APK is installed, §14.8) | M8, M7g, SPEC v0.12 | +| M12f | Audit and security follow-ups (Android) | Landed `dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab` 2026-10-18 (D-109 to D-112, D-116; no prompt file, orchestrator brief). S-16 read-only lost version (`Rejection.lost`, D-109); S-22 versions from `/health` (`AboutState.schemaVersion`, D-110); `SettlementRepository` without throws (D-111); E-17 retry (D-112); invite links dropped while paired (AC-2.6); private notifications (AC-13.9, D-116); `localeFilters` en, sv (AC-19.1); `:data` tests for the 429 retry, D-040 refusals and WorkManager unique work (`work-testing`, §6.2); the `:feature:home` and `:feature:account` screenshot harness fix (§6.2); the 19 string keys R8 found unused removed (`strings.csv` 562 rows, §13.4) | Android gate (green at landing, 941 tests); release APK 10.0 MB | Look at S-16 and S-22 on the next install (HANDOFF §3) | M8f, the AC-coverage audit and the security review (§11.4) | +| M9 | Splitwise importer | Landed `50339ae` (D-044 to D-046). `kvitt-import` per §10.8 with tests over synthetic CSV fixtures (`crates/kvitt-import/tests/import.rs`), reconciliation against the totals rows, the committed category table | Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 | | M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore with the `keytool` command of §10.6 at the repository root, or allow an agent to (D-101), and keep a copy and the password in the password manager; run the Pi steps from the M2c-pi bundle (D-100); install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi (the owner's run of the bundle), M3, M4, M8 | | M11 | Import history | Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044). Dry run of 2026-10-18 with the M12b mapping (D-053): Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. Real import run on the Pi per §10.8 "Production run" (D-025), `docs/validation/splitwise-import.md` with per-account totals compared to Splitwise's balances (aggregates only) | Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 | @@ -1212,24 +1251,35 @@ After M1, M2a and M2b land, these run in parallel without touching each other's * **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: `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//` 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). All six landed on 2026-10-18 (`b9b061a` M7a, `6ac711d` M7e, `082b8dc` M7d, `31760f9` M7b, `1e42f76` M7c, `a54eb7c` M7f); their readings are D-069 to D-074 and D-080, the shared fixes they asked for went into M6b (`3a1bd14`, D-075 to D-079). M7g (landed `34a2590`) did the feature-side cleanups after M6b. -* **M6b** (landed `3a1bd14`) is the `:core:design` fix pass after the M7 wave, in M6's paths (§14.7); see the §11.2 row. -* **M12e** (landed `1470a2f` and `90505a8` on 2026-10-18, D-086 to D-095) took the contract requests that M5, M6, M7 and M8 had collected here, as one contract change on both sides (§14.8): the invite kind from `pair()` (D-086), the admin's name in invite links (D-087), `SyncSummary.lastAttemptFailed` (D-088), `Rejection.actor` (D-089), the device version name with server schema 3 (D-090), `NewRecurringTemplate` without an id (D-091), `UpdateCheck` (D-092), `SettleState.untouched` (D-093), the D-027 re-stamp in `:data` (D-094), and `PushEvent` in `:core:model`, the six screen-hosted snackbar hosts, snackbar resource ids from S-7 and S-8 and the launcher icon (D-095). The 128 px picture size was left unchanged by assumption: `:data` keeps fetching E-11 at 512 and no size parameter was added, since v1 does not need one (the owner confirmed on 2026-10-18 that it is not needed: single 512 size, §14.2.4). Room stays at schema 1 (the phone stores no devices). A phone running the M12e APK needs a server at schema 3 (§14.8 "Compatibility"). +* **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//` 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). All six landed on 2026-10-18 (`1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f); their readings are D-069 to D-074 and D-080, the shared fixes they asked for went into M6b (`b24c8b5`, D-075 to D-079). M7g (landed `3dda414`) did the feature-side cleanups after M6b. +* **M6b** (landed `b24c8b5`) is the `:core:design` fix pass after the M7 wave, in M6's paths (§14.7); see the §11.2 row. +* **M12e** (landed `8c60715` and `b7b7483` on 2026-10-18, D-086 to D-095) took the contract requests that M5, M6, M7 and M8 had collected here, as one contract change on both sides (§14.8): the invite kind from `pair()` (D-086), the admin's name in invite links (D-087), `SyncSummary.lastAttemptFailed` (D-088), `Rejection.actor` (D-089), the device version name with server schema 3 (D-090), `NewRecurringTemplate` without an id (D-091), `UpdateCheck` (D-092), `SettleState.untouched` (D-093), the D-027 re-stamp in `:data` (D-094), and `PushEvent` in `:core:model`, the six screen-hosted snackbar hosts, snackbar resource ids from S-7 and S-8 and the launcher icon (D-095). The 128 px picture size was left unchanged by assumption: `:data` keeps fetching E-11 at 512 and no size parameter was added, since v1 does not need one (the owner confirmed on 2026-10-18 that it is not needed: single 512 size, §14.2.4). Room stays at schema 1 (the phone stores no devices). A phone running the M12e APK needs a server at schema 3 (§14.8 "Compatibility"). * **Remaining contract requests** (none is a contract change in v0.14; it needs the §14.8 rule when taken): - * None open. S-22's "Licenser för öppen källkod" did nothing (no licences screen and no strings for one); the owner answered OQ-5 on 2026-10-18: no licences screen in v1, the row is removed (implemented by M8c, `64f5706`, D-099). + * None open. S-22's "Licenser för öppen källkod" did nothing (no licences screen and no strings for one); the owner answered OQ-5 on 2026-10-18: no licences screen in v1, the row is removed (implemented by M8c, `a0c25b0`, D-099). - The `fid` request was taken on 2026-10-18 (`c694f78`, D-096): the server sends the installation id in FCM HTTP v1 `message.fid` (§14.6). + The `fid` request was taken on 2026-10-18 (`f56dcbe`, D-096): the server sends the installation id in FCM HTTP v1 `message.fid` (§14.6). * **M2c-dev / M2c-pi** (D-001, D-100): M2c-dev builds only what development needs on the Mac (landed); 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) as the `infra/pi/` bundle the owner runs, and runs last, after M8 and the phone test, before M10. -* **M4** (landed `bd6f10a`) added `into_make_service_with_connect_info::()` to `serve` so the per-IP rate limits of §8.6 see the peer address (D-032). Its tests are three binaries, `tests/sync_api/`, `tests/push_fcm/` and `tests/scheduler_daily/` (§14.7); the scheduler lives in `src/tasks/scheduler.rs`. -* **M8** (landed `369e516` and `00a14a3`, D-081 to D-085) is the only plan that edits `:app` after M2b. As built: both variants bind `:data` through `di/AppDataModule.kt`, which includes `DataBindingsModule`; the debug fakes module is deleted (D-081, revising D-041). The session gate shows S-1 for Unpaired and Unrecognised, S-2, and S-18 for Revoked (back on S-18 closes the app); Home shows S-3 while `partner == null`; each top-level screen draws its own bar (D-071). M6's `:app` requests are met: `ProvideHousehold` is implemented as `LocalKvittHousehold`, `LocalKvittClock` is provided from `:data`'s Clock, and `LocalAvatarPictures` passes the `User` model to `:data`'s Coil mapper. M5's requests are met: `AppBuildInfo` from `BuildConfig`; WorkManager with `KvittWorkerFactory` and the default initializer disabled; `SyncTriggers.onAppStart` on process start and `onForeground` on later returns; push handled by an expedited WorkManager job calling `syncForPush` (D-085); `UpdateRepository.check` on every start and resume, in release builds only (AC-16.5); Coil's singleton is `:data`'s `ImageLoader`. The notification permission is asked after the first successful sync after pairing (AC-2.9). Network security configs per §8.5 (D-082); the merged release manifest exports only `MainActivity` and permission-guarded library receivers (§8.8); `:app:assembleRelease` builds unsigned when `local.properties` has no keystore keys (§10.6); FCM registration by Firebase Installation ID (D-083); invite links ignored while paired (D-084). The end-to-end test ran on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device` (the CLI has no partner-invite subcommand; small ops request for a later server plan); Home showed the account; an expense added offline showed the pending marker and synced after reconnect (pending 0, the server has the row); the owner's device registered `fcm_token` and `app_version_name`; a push by `fid` was delivered and the notification tap worked. **M8b** (landed `f0575d3`) fixed what the owner saw: the invite-code field holds the raw code and draws the hyphens as a `VisualTransformation` with an exact offset mapping (cursor bug fixed, unit test `InviteCodeFieldTest`); the quick-row and grid selected badge is drawn above the ripple clip (it was cut off); `KvittTopAppBar` pads for the status-bar inset through a `windowInsets` parameter and `KvittScaffold` follows; chart bar corners top 4, bottom 2 dp (D-097); 221 goldens re-recorded. `FcmMessageBody.fid` in `:data` followed in `09f474f` (D-096). Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked and had no complaint). Its contract requests were taken by M12e (D-086 to D-095); the `fid` field followed in `c694f78` (D-096). **M8e** (landed `15397e4`, D-107) turned S-19's account, payer and split chips into `EditorOptionRows`; the unused `AccountChip`, `PayerChip` and `SplitChip` were removed in `4755fe0`. **M8f** (landed `ed1b130`, D-108) made navigation instant (`NavTransitions` in `:app`). +* **M4** (landed `15056dc`) added `into_make_service_with_connect_info::()` to `serve` so the per-IP rate limits of §8.6 see the peer address (D-032). Its tests are three binaries, `tests/sync_api/`, `tests/push_fcm/` and `tests/scheduler_daily/` (§14.7); the scheduler lives in `src/tasks/scheduler.rs`. +* **M8** (landed `cb832a7` and `3d9eea8`, D-081 to D-085) is the only plan that edits `:app` after M2b. As built: both variants bind `:data` through `di/AppDataModule.kt`, which includes `DataBindingsModule`; the debug fakes module is deleted (D-081, revising D-041). The session gate shows S-1 for Unpaired and Unrecognised, S-2, and S-18 for Revoked (back on S-18 closes the app); Home shows S-3 while `partner == null`; each top-level screen draws its own bar (D-071). M6's `:app` requests are met: `ProvideHousehold` is implemented as `LocalKvittHousehold`, `LocalKvittClock` is provided from `:data`'s Clock, and `LocalAvatarPictures` passes the `User` model to `:data`'s Coil mapper. M5's requests are met: `AppBuildInfo` from `BuildConfig`; WorkManager with `KvittWorkerFactory` and the default initializer disabled; `SyncTriggers.onAppStart` on process start and `onForeground` on later returns; push handled by an expedited WorkManager job calling `syncForPush` (D-085); `UpdateRepository.check` on every start and resume, in release builds only (AC-16.5); Coil's singleton is `:data`'s `ImageLoader`. The notification permission is asked after the first successful sync after pairing (AC-2.9). Network security configs per §8.5 (D-082); the merged release manifest exports only `MainActivity` and permission-guarded library receivers (§8.8); `:app:assembleRelease` builds unsigned when `local.properties` has no keystore keys (§10.6); FCM registration by Firebase Installation ID (D-083); invite links ignored while paired (D-084). The end-to-end test ran on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device` (the CLI had no partner-invite subcommand then; `kvitt-server invite-partner` followed in `8660b7f`, D-113); Home showed the account; an expense added offline showed the pending marker and synced after reconnect (pending 0, the server has the row); the owner's device registered `fcm_token` and `app_version_name`; a push by `fid` was delivered and the notification tap worked. **M8b** (landed `328c466`) fixed what the owner saw: the invite-code field holds the raw code and draws the hyphens as a `VisualTransformation` with an exact offset mapping (cursor bug fixed, unit test `InviteCodeFieldTest`); the quick-row and grid selected badge is drawn above the ripple clip (it was cut off); `KvittTopAppBar` pads for the status-bar inset through a `windowInsets` parameter and `KvittScaffold` follows; chart bar corners top 4, bottom 2 dp (D-097); 221 goldens re-recorded. `FcmMessageBody.fid` in `:data` followed in `6eb3f85` (D-096). Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked and had no complaint). Its contract requests were taken by M12e (D-086 to D-095); the `fid` field followed in `f56dcbe` (D-096). **M8e** (landed `a59abeb`, D-107) turned S-19's account, payer and split chips into `EditorOptionRows`; the unused `AccountChip`, `PayerChip` and `SplitChip` were removed in `1328afe`. **M8f** (landed `956908a`, D-108) made navigation instant (`NavTransitions` in `:app`). * **M10**: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 at the repository root and referenced from `android/local.properties` (D-024, D-101). -* **M2c-pi** (D-100, revises D-023): no SSH from the Mac to the Pis and no allowances for agents on the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. The agent writes and checks the `infra/pi/` bundle on the Mac; the owner copies it to the Kvitt Pi, fills the `.env` placeholders (OQ-3) and runs the README checklist, answering yes/no per check. It runs as the last step before M10, after the app is proven on the phone. The bundle landed `5e027df` on 2026-10-18 (D-103 to D-106: layout and scripts, image names and the Gitea workflow, the backup uid, the cert-sync and backup refinements); the remaining part is HUMAN, the owner running the bundle, and M10 depends on it. -* **M9** (landed `ab298ab`) reuses the server's own push validation (`kvitt_server::api::validate`, `rows`) and the `SqlAuditWriter`, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary, `crates/kvitt-import/tests/import.rs`, over synthetic fixtures only; one test checks the §10.8 category table against `data/splitwise_categories.csv`. -* **M12a, M12b, M12c** (D-049 to D-054) carry the owner's UX-session decisions into the contracts and the landed code: M3, M5 and M9 were built with ten defaults, so this is a contract change against `main`, not only the spec. M12a (landed `6b60ef7`) wrote `contracts/`, the checker and `:domain`'s `suggestCategory`; M12b (landed `2e2ebe7`) and M12c (landed `e499107`) ran in parallel (disjoint paths, §14.7); their outcomes are D-055 to D-058. The Room schema stays at version 1: category rows reach the phone through sync. The colour hex values of `slate` and `amber` are not needed by M12 (`:core:model` holds token names only); they arrive with U2 and M6. -* **M12d** (D-059, landed `bdba9d3`) added the two UI-contract members UX-9 found (`ExpenseEditorOptions.frequentCategoryIds`, `CategoryRepository.deletable`) to `:core:model`, `:data` and `:core:fakes`, in parallel with M6 (disjoint paths); its readings are D-060 to D-062 (§14.2.4 "Readings fixed in v0.10"). M7c and M7f consume them; the design spec's fallbacks (`docs/ux/design-spec.md` §3.7 and §6) are no longer needed. -* **M6** (landed `3080804`) built `:core:design` with charts on Compose `Canvas` (D-063), the Material 3 Expressive fallbacks of D-064, the formatting tables of D-065 and the golden layout of D-066. Its contract requests for **M8** (no change now): `:app` provides `LocalAvatarPictures` with an `AvatarPictures` that builds the authenticated E-11 Coil request from the user id and `pictureVersion` (§14.3.4); `:app` wraps every screen in `ProvideHousehold(me, partner)`; `:app` provides `LocalKvittClock` (tests and previews pass a fixed clock); `SyncSummary` gains a "last attempt failed" field so `SyncStatusPill` can drop its `failed` parameter (§13.3 S-4); done by M12e: `SyncSummary.lastAttemptFailed`, and `SyncStatusPill`, `syncPillVariant` and `syncLineText` no longer take `failed` (D-088). The design-spec §2.7 open point (that `FontFamily.Default` keeps tabular figures and distinct weights 500/600/700 under every One UI font style on the S26) is checked at the M8 install on the owner's phone; on 2026-10-18 the owner looked and had no complaint (not checked under every One UI font style). +* **M2c-pi** (D-100, revises D-023): no SSH from the Mac to the Pis and no allowances for agents on the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. The agent writes and checks the `infra/pi/` bundle on the Mac; the owner copies it to the Kvitt Pi, fills the `.env` placeholders (OQ-3) and runs the README checklist, answering yes/no per check. It runs as the last step before M10, after the app is proven on the phone. The bundle landed `2476667` on 2026-10-18 (D-103 to D-106: layout and scripts, image names and the Gitea workflow, the backup uid, the cert-sync and backup refinements); the remaining part is HUMAN, the owner running the bundle, and M10 depends on it. +* **M9** (landed `50339ae`) reuses the server's own push validation (`kvitt_server::api::validate`, `rows`) and the `SqlAuditWriter`, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary, `crates/kvitt-import/tests/import.rs`, over synthetic fixtures only; one test checks the §10.8 category table against `data/splitwise_categories.csv`. +* **M12a, M12b, M12c** (D-049 to D-054) carry the owner's UX-session decisions into the contracts and the landed code: M3, M5 and M9 were built with ten defaults, so this is a contract change against `main`, not only the spec. M12a (landed `aa765bb`) wrote `contracts/`, the checker and `:domain`'s `suggestCategory`; M12b (landed `6eaccec`) and M12c (landed `b09f322`) ran in parallel (disjoint paths, §14.7); their outcomes are D-055 to D-058. The Room schema stays at version 1: category rows reach the phone through sync. The colour hex values of `slate` and `amber` are not needed by M12 (`:core:model` holds token names only); they arrive with U2 and M6. +* **M12d** (D-059, landed `557af7e`) added the two UI-contract members UX-9 found (`ExpenseEditorOptions.frequentCategoryIds`, `CategoryRepository.deletable`) to `:core:model`, `:data` and `:core:fakes`, in parallel with M6 (disjoint paths); its readings are D-060 to D-062 (§14.2.4 "Readings fixed in v0.10"). M7c and M7f consume them; the design spec's fallbacks (`docs/ux/design-spec.md` §3.7 and §6) are no longer needed. +* **M6** (landed `7730a31`) built `:core:design` with charts on Compose `Canvas` (D-063), the Material 3 Expressive fallbacks of D-064, the formatting tables of D-065 and the golden layout of D-066. Its contract requests for **M8** (no change now): `:app` provides `LocalAvatarPictures` with an `AvatarPictures` that builds the authenticated E-11 Coil request from the user id and `pictureVersion` (§14.3.4); `:app` wraps every screen in `ProvideHousehold(me, partner)`; `:app` provides `LocalKvittClock` (tests and previews pass a fixed clock); `SyncSummary` gains a "last attempt failed" field so `SyncStatusPill` can drop its `failed` parameter (§13.3 S-4); done by M12e: `SyncSummary.lastAttemptFailed`, and `SyncStatusPill`, `syncPillVariant` and `syncLineText` no longer take `failed` (D-088). The design-spec §2.7 open point (that `FontFamily.Default` keeps tabular figures and distinct weights 500/600/700 under every One UI font style on the S26) is checked at the M8 install on the owner's phone; on 2026-10-18 the owner looked and had no complaint (not checked under every One UI font style). +* **M12f** (landed `dc7a296` to `a0259ab`, D-109 to D-112, D-116) took the Android findings of the AC-coverage audit and the security review (§11.4); see the §11.2 row. The S-1 confirm step for an unusual server (security review L-6) was not built: it needs a design state (OQ-6). +* **Server gap pass** (landed `8660b7f`, D-113): `kvitt-server invite-partner` (§10.4), which closes the ops request of the M8 test; tests for `invite-partner`, `migrate` (schema at `SCHEMA_VERSION`, idempotent), `healthcheck` (200, 503, nothing listening) and the SIGTERM drain of `serve`; the push sender's `push sent` info line (§8.7); `tools/contracts-check.py` follows `invite_kind` (D-086), `app_version_name` (D-090) and `fid` (D-096), 134 checks pass. +* **Server overhaul** (landed `8103dae` to `b985ce9`, D-114, D-115): shared stored-row reads and inserts (`src/api/rows.rs`, `src/sync/rows.rs`), shared invite creation and CLI plumbing, one test harness (`tests/common/`, §14.7), clippy pedantic wins, synchronous `tasks::start` (§6.3), `not_implemented` no longer produced (§14.3.2); the security fixes H-1 (D-115), M-3 and L-4 (D-114). Gate at landing: 268 server tests; all crates at their latest versions. +* **Infra overhaul** (landed `2fac1ec` to `e70fe0d`, no decision): one Gradle lock helper `tools/gradle-lock.sh` for `tools/gradle-serial.sh`, `tools/clean-builds.sh` and `infra/build-images.sh`; the pre-commit hook runs the Android tests through `tools/gradle-serial.sh` with JDK 25 on `PATH`, `JAVA_HOME` untouched; `install.sh` keeps the deployed `KVITT_TAG` (§10.3); `--provenance=false` on buildx pushes and the workflow token through the environment (§10.3); `RCLONE_REMOTE` dropped from the development env (§10 intro). **Infra security** (landed `7fb86f5`, D-117): Caddy log redaction and response headers (§8.7, §10.5.2), the non-root Caddy container (§8.9, §10.2), the development stack's single secret (§10 intro), the digest record (§10.3, §10.10), `kvitt_cicd` with `write:package` only (§10.3), the restricted cert-sync key (§10.5.1). * **M11** runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. +### 11.4 Validation reports + +Reports in `docs/validation/` are deliverables (`CLAUDE.md`); they carry aggregates only and record the commit they were taken at. The release reports `first-release.md` and `restore-drill.md` (M10, AC-18.2) are still to be written. + +* `docs/validation/ac-coverage.md`, the AC-coverage audit taken at `956908a` (`ed1b130` before the history rewrite; SPEC v0.16), read-only: 270 rows, 260 Done, 5 Partial, 1 Missing, 4 not verifiable without a device. Verdict: feature complete in code; not yet complete against §4's acceptance rule. Its two mismatches in shipped behaviour are fixed by M12f: S-16 opens the lost change read-only (D-109) and S-22 reads the server version from `/health` (D-110); M12f also added tests for the §9.2 effect cases, AC-2.9, the 429 retry, D-040 refusals and WorkManager unique work. Still open: the AC-18.2 restore drill (Missing), AC-18.1 and AC-20.1 observed on the Pi, AC-16.3 the real PackageInstaller install (all M2c-pi or M10), and a `KvittWorkerFactory` test. +* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device) is left as the spec says, decision pending with the owner; M-1 (backup encryption) and L-3 (APK download rate) wait for the owner. H-1 must be fixed before port 8443 is opened to the internet. + --- ## 12. Open questions @@ -1242,7 +1292,8 @@ Only things that cannot be decided without the owner. Everything else in this sp | OQ-2 | Answered (D-003): two exports, English and Swedish header variants, member columns in either order, ISO dates, point decimal, double-quoted fields, trailing empty line and `Total balance` / `Totalsumma` row, payment category `Payment` / `Betalning`, all rows SEK. Group "hemma" becomes account "Hemma", the general group becomes account "Vardag". Details §10.8 and PLAN-M9. | M9, M11 | answered | | OQ-3 | Answered by placeholders (D-100): the LAN address of the Kvitt Pi; LAN address, SSH port and user of the Home Assistant Pi; the directory holding `fullchain.pem` and `privkey.pem` there (expected `/ssl` on Home Assistant OS); which SSH add-on is used. The owner fills them in the M2c-pi bundle's `.env` on the Pi; no agent needs them. | M2c-pi | answered | | OQ-4 | Answered: the Google Drive backup folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (§10.9). | M2c-pi | answered | -| OQ-5 | Answered 2026-10-18 and implemented (D-099, `64f5706`): no licences screen in v1; S-22 has no "Licenser för öppen källkod" row. | M10 | answered | +| OQ-5 | Answered 2026-10-18 and implemented (D-099, `a0c25b0`): no licences screen in v1; S-22 has no "Licenser för öppen källkod" row. | M10 | answered | +| OQ-6 | Should S-1 ask for a confirmation before pairing when an invite link prefilled a server other than `https://icehack.asuscomm.com:8443` (security review L-6)? It needs a design state for S-1 (copy and layout) and strings. | M10 | Not in v1: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116) | --- @@ -1294,13 +1345,13 @@ Each screen must be designed in these states where they apply: **loaded**, **emp | S-13 | Settings hub | Own avatar and name; partner's avatar and name; rows: Profile, Categories, Recurring expenses, Devices, Invite partner or Pair a device (admin), Sync status, About | Navigate | | S-14 | Profile | Avatar with change/remove; display name; "Needs connection" state | Save | | S-15 | Categories | List: icon, colour, name, archived section; editor: name (with "Reset name" for defaults), icon grid (33 icons of §9.7), colour swatches (14 tokens); delete or archive with the reason when delete is not allowed | Add; Edit; Reorder (drag); Archive; Delete | -| S-16 | Sync status | Last successful sync time; pending changes count with a list (the shared ledger rows of `:core:design`, D-019, not a separate 36 dp row); "Changes not synced" list from `sync_rejections` with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync failed" when online and `SyncSummary.lastAttemptFailed` (D-088); the stale-change copy names `Rejection.actor`, the partner when it is null (D-089); "Sync now", online only (§9.9, D-080) | Sync now; open a rejected change (read-only); dismiss it | +| S-16 | Sync status | Last successful sync time; pending changes count with a list (the shared ledger rows of `:core:design`, D-019, not a separate 36 dp row); "Changes not synced" list from `sync_rejections` with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync failed" when online and `SyncSummary.lastAttemptFailed` (D-088); the stale-change copy names `Rejection.actor`, the partner when it is null (D-089); "Sync now", online only (§9.9, D-080); "Öppna" on a rejection shows the phone's lost version read-only (D-109): a dialog "Din version" (`home_sync_lost_title`, body `home_sync_lost_body`) with the ledger row it would have been (read-only: the row's `onClick` is null), the account, payer, split and notes (`Rejection.lost`, §14.2.4), the badge "Du tog bort den här" (`home_sync_lost_deleted`) when the refused change was a deletion, and "Visa nuvarande version" (`home_sync_lost_show_current`), which opens S-8 or S-10; when `lost` is null (unreadable payload, or its category or a user is not on the phone) "Öppna" opens the current version directly | Sync now; open a rejected change (read-only); dismiss it | | S-17 | Update available | Banner on Home with version name; dialog explaining "Install unknown apps" the first time; download progress; failure | Update; Later | | S-18 | Device removed | Explanation that this phone's access was removed and local data deleted | Pair again (goes to S-1) | | S-19 | Recurring expenses | List: description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 (no date); account, payer and split as three fixed full-width rows (Konto, Betalt av, Delning; D-102, D-107) plus day of month (a new template defaults to today's day, AC-9.8, D-072), first month, optional last month, pause switch; a new template is sent as `NewRecurringTemplate` (`RecurringForm.newTemplate()`), an edit as `RecurringTemplate` (`RecurringForm.template(original, …)`), D-091; the discard dialog has no body text (D-080) | Add; Edit; Pause/Resume; Delete | | S-20 | Devices | Rows per AC-3.1; the app version shows `Device.appVersionName` ("Kvitt 1.1"), else the version code (D-090) | Revoke (with confirmation) | | S-21 | Invites | For the admin: "Invite partner" (only before the partner joins) and "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry, and a share action (Android share sheet); list of pending invites with cancel | Create; Share; Cancel | -| S-22 | About | App version name and code, server URL, server version (from `/health`); no open-source licences row (OQ-5, D-099); after "Check for update": `UpdateCheck.UpToDate` shows "Du har den senaste versionen" (`settings_about_up_to_date`), `UpdateCheck.Failed` shows "Kunde inte nå servern" (`common_could_not_reach_server`) (AC-16.6, D-092) | Check for update | +| S-22 | About | App version name and code, server URL, server version from E-1 `/health`, fetched when S-22 opens while online, shown as " (schema )" (`settings_about_server_version_schema`, "1.1.0 (schema 3)"); offline or when the call fails, the `X-Kvitt-Server-Version` header of the last response without the schema (`AboutState`, §14.2.4, D-110); no open-source licences row (OQ-5, D-099); after "Check for update": `UpdateCheck.UpToDate` shows "Du har den senaste versionen" (`settings_about_up_to_date`), `UpdateCheck.Failed` shows "Kunde inte nå servern" (`common_could_not_reach_server`) (AC-16.6, D-092) | Check for update | System notifications (copy needed in both languages, built per AC-13.5): @@ -1344,7 +1395,7 @@ Suggested starting points the UX agent may use, replace or merge (it must delive 2. **"Skärgård"**: Material 3 Expressive with a cool sea-and-granite palette, large rounded shapes, bold balance header, playful but quiet. 3. **"Kontor"**: dense, utilitarian, data-forward. Neutral greys, strong typographic hierarchy, charts as the hero on Analytics, minimal decoration. -**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`. The owner picked direction 3 "Kalas" with the muted palette of `docs/ux/directions/3-kalas/`, typeface per D-020 (D-047). U2 landed `16c4d01`, approved by the owner. `docs/ux/design-spec.md` is the binding design document for `:core:design` (§2 tokens, §3 components) and for every screen (§4, §5); this spec wins over it on rules. Amended after U2: chart bars have top corners 4 dp and bottom corners 2 dp (D-097, design-spec §2.9 and §3.10); S-7's account, date, payer and split options are four fixed rows, `EditorOptionRows`, and connected buttons have 8 dp side padding (D-102, design-spec §3.7 and §3.10); S-22 has no licences row (D-099, design-spec S-22); the mock-up PNGs are compressed losslessly (D-098). `docs/ux/strings.csv` (558 rows at U2, header included; 559 since v0.10 with `ledger_settlement_label`, D-068, and English "Dining" for `category_default_restaurants`, D-067; 578 since v0.11 with the shared keys `common_ok`, `common_undo` and `editor_split_full_on_you` and the module keys of M7b to M7f recorded as built, D-079; 577 since v0.16 without `settings_about_licences`, D-099; keys with the D-017 prefixes; plurals as `key:one` / `key:other`) is the source of every string resource: +**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`. The owner picked direction 3 "Kalas" with the muted palette of `docs/ux/directions/3-kalas/`, typeface per D-020 (D-047). U2 landed `6da6b1b`, approved by the owner. `docs/ux/design-spec.md` is the binding design document for `:core:design` (§2 tokens, §3 components) and for every screen (§4, §5); this spec wins over it on rules. Amended after U2: chart bars have top corners 4 dp and bottom corners 2 dp (D-097, design-spec §2.9 and §3.10); S-7's account, date, payer and split options are four fixed rows, `EditorOptionRows`, and connected buttons have 8 dp side padding (D-102, design-spec §3.7 and §3.10); S-22 has no licences row (D-099, design-spec S-22); S-16's lost version (D-109) and S-22's "(schema )" (D-110), design-spec S-16 and S-22; the mock-up PNGs are compressed losslessly (D-098). `docs/ux/strings.csv` (558 rows at U2, header included; 559 since v0.10 with `ledger_settlement_label`, D-068, and English "Dining" for `category_default_restaurants`, D-067; 578 since v0.11 with the shared keys `common_ok`, `common_undo` and `editor_split_full_on_you` and the module keys of M7b to M7f recorded as built, D-079; 577 since v0.16 without `settings_about_licences`, D-099; 562 since v0.18: M12f added `home_sync_lost_title`, `home_sync_lost_body`, `home_sync_lost_deleted`, `home_sync_lost_show_current` (D-109) and `settings_about_server_version_schema` (D-110) and removed the 19 keys (20 rows) that R8 found unused (`a0259ab`); keys with the D-017 prefixes; plurals as `key:one` / `key:other`) is the source of every string resource: * `design-spec.md`: final tokens (colour roles light and dark, category colour hex values, typography scale, shapes, spacing scale in dp, elevation, motion durations and easing), component inventory (each with anatomy, states, sizes), and for every screen S-1 to S-22: layout description, all states of §13.3, interaction details, TalkBack labels. * `strings.csv`: every user-visible string of the app with columns `key,en,sv,notes`, keys in `snake_case` prefixed by the feature module that holds them (`home_balance_owes_you`, `expense_save`; shared strings for `:core:design` never start with a feature name; the app name is the only `:app` string; §6.2, D-017), placeholders as Android format arguments (`%1$s`), plurals marked in `notes` with their quantities. This becomes `values/strings.xml` and `values-sv/strings.xml` in M6 and M7; feature agents add no strings that are not in this file without a decisions entry. Keys recorded by D-079 (v0.11): shared `common_ok`, `common_undo`, `editor_split_full_on_you` ("All on you" / "Allt på dig"; `ledger_settlement_label` was D-068); module keys as built: `analytics_granularity`, `analytics_measure`, `analytics_group_by`, `analytics_range_apply`; `settle_row_amount`, `settle_detail_delete`, `settle_detail_sync_now`; `expense_detail_sync_now`; `home_archived_expanded`, `home_archived_collapsed`; `settings_category_archived_snackbar`, `settings_category_unarchived_snackbar`, `settings_recurring_summary_none`, `settings_expanded`, `settings_collapsed`, `settings_partner_missing`. @@ -1608,7 +1659,7 @@ Server-side rules on top of the DDL: #### 14.1.1 Migration 0002: schema version 2 (D-054) -Migration 0002 adds the four default categories `housing`, `insurance`, `children` and `fees` of AC-7.2 to every database, existing ones (the development stack included) on upgrade and new ones right after 0001. The SQL below is the content of the contract file `contracts/schema/migrations/0002_categories_v2.sql` as M12a committed it (`6b60ef7`; the file wins over this copy) and of the server migration `server/crates/kvitt-server/migrations/0002_categories_v2.sql`, byte for byte (a test asserts equality, as for 0001); `contracts/schema/server-v1.sql` stays unchanged. Every later migration follows the same pattern: `contracts/schema/migrations/_.sql` is its locked text. +Migration 0002 adds the four default categories `housing`, `insurance`, `children` and `fees` of AC-7.2 to every database, existing ones (the development stack included) on upgrade and new ones right after 0001. The SQL below is the content of the contract file `contracts/schema/migrations/0002_categories_v2.sql` as M12a committed it (`aa765bb`; the file wins over this copy) and of the server migration `server/crates/kvitt-server/migrations/0002_categories_v2.sql`, byte for byte (a test asserts equality, as for 0001); `contracts/schema/server-v1.sql` stays unchanged. Every later migration follows the same pattern: `contracts/schema/migrations/_.sql` is its locked text. * The v1 `CHECK` on `categories.default_key` lists ten keys and SQLite cannot alter a `CHECK`, so the migration rebuilds `categories` with the fourteen keys (the ten of v1 followed by the four new ones) and otherwise the same columns and constraints, keeping every row unchanged (custom categories, renames, archive state, `server_version`). * sqlx runs the migration in one transaction on a connection with `foreign_keys = ON` (§8.9), where `PRAGMA foreign_keys` cannot change. `PRAGMA defer_foreign_keys = ON` defers the checks to the commit; the rows are copied out, the table dropped and recreated under its own name, and the rows copied back, so every reference from `expenses` and `recurring_templates` resolves again before the commit. @@ -1685,7 +1736,7 @@ UPDATE meta SET value = '2' WHERE key = 'schema_version'; #### 14.1.2 Migration 0003: schema version 3 (D-090) -Migration 0003 adds the column `devices.app_version_name` (the app's `versionName`, from the request header `X-Kvitt-App-Version-Name`, §14.3.1), written like `app_version_code` (§8.2). The SQL below is the content of the contract file `contracts/schema/migrations/0003_device_version_name.sql` as M12e committed it (`1470a2f`; the file wins over this copy) and of the server migration `server/crates/kvitt-server/migrations/0003_device_version_name.sql`, byte for byte (a test asserts equality, as for 0001 and 0002). +Migration 0003 adds the column `devices.app_version_name` (the app's `versionName`, from the request header `X-Kvitt-App-Version-Name`, §14.3.1), written like `app_version_code` (§8.2). The SQL below is the content of the contract file `contracts/schema/migrations/0003_device_version_name.sql` as M12e committed it (`8c60715`; the file wins over this copy) and of the server migration `server/crates/kvitt-server/migrations/0003_device_version_name.sql`, byte for byte (a test asserts equality, as for 0001 and 0002). * Existing devices keep every column and get `NULL` until their next request that carries the header (at most once per hour, §8.2); E-3 stores it at pairing. * `meta.schema_version` becomes `3`; the server's `SCHEMA_VERSION` constant (E-1) becomes 3, and the importer's check (§10.8) follows. A migration test upgrades a v2 database in use to schema 3 and asserts that every row is kept. @@ -1869,8 +1920,9 @@ data class SettleState(val payer: User, val recipient: User, val amountOre: Long data class SettleRequest(val editing: SettlementId?, val payer: UserId, val amountOre: Long?, val fixedAccount: AccountId?, val allocations: Map?, val date: String, val note: String) data class SettlementDetailState(val id: SettlementId, val from: User, val to: User, val amountOre: Long, val date: String, val note: String, val allocations: List>, val deleted: Boolean, val pending: Boolean, val timeline: List) interface SettlementRepository { - suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState // defaults per AC-11.3 / AC-11.4 - suspend fun recompute(request: SettleRequest): SettleState // allocations == null: propose (§9.4); else validate and warn + suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState? // defaults per AC-11.3 / AC-11.4; null without a paired session or partner (v0.18, D-111) + suspend fun recompute(request: SettleRequest): SettleState? // allocations == null: propose (§9.4); else validate and warn; null as start (D-111) + // save without a session or partner: SaveResult.Invalid([FieldError("payer", "validation_failed")]); no method throws (D-111) suspend fun save(request: SettleRequest): SaveResult suspend fun delete(id: SettlementId) fun detail(id: SettlementId): Flow @@ -1922,7 +1974,11 @@ interface InviteRepository { suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult suspend fun cancel(id: InviteId): OnlineResult } -data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String, val actor: User?) // actor: v0.13, D-089 +data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String, val actor: User?, val lost: RejectedVersion?) // actor: v0.13, D-089; lost: v0.18, D-109 +// D-109: the refused version as pushed (sync_rejections.rejected_payload), shown read-only on S-16; lost is null when the payload +// cannot be decoded or its category or a user is missing on the phone. row is the ledger row it would have been (pending false); +// for a settlement accountName, payer and mode are null and shares empty; notes is the expense's notes or the settlement's note. +data class RejectedVersion(val row: LedgerRow, val accountName: String?, val payer: User?, val mode: SplitMode?, val shares: List, val notes: String, val deleted: Boolean) data class SyncStatusState(val summary: SyncSummary, val pending: List, val rejections: List) interface SyncRepository { fun status(): Flow @@ -1947,7 +2003,9 @@ interface UpdateRepository { suspend fun check(): UpdateCheck // v0.13, D-092 suspend fun install() } -data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?) +data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?, val schemaVersion: Int?) // schemaVersion: v0.18, D-110 +// D-110: serverVersion and schemaVersion from E-1 /health when S-22 opens online; offline or on failure serverVersion is the +// X-Kvitt-Server-Version header of the last response and schemaVersion is null. interface AboutRepository { fun about(): Flow } interface ConnectivityRepository { val online: Flow } // §9.9 @@ -1978,7 +2036,7 @@ Changes in v0.9 (D-055 to D-057, D-059): * **`ExpenseEditorOptions.frequentCategoryIds` (D-059).** Per active account: the active (non-archived, non-deleted) categories ordered by the number of non-deleted expenses in that account that use them, descending; ties by `sort_order` ascending; at most 5 entries; categories with no use in the account are left out (the list may be shorter or empty). Counted over every expense in the phone's Room copy (both users, imported and recurring included), so both phones show the same order after sync; it is not a per-phone history like AC-8.6's "last used on this phone". The flow re-emits when expenses or categories change; the editor reads it once when it opens and on an account change, so tiles do not move while the user looks at them. UI rule (design, not contract): the row shows the account's list, filled up to five from the remaining active categories in `sort_order`; the selected category takes the fifth place when it is not among them. Fallback until M12d lands: the selected category first, then the first active categories in `sort_order` up to five. * **`CategoryRepository.deletable(id)` (D-059).** Computed from Room: false for a default category (`defaultKey != null`) or one used by a non-deleted expense or non-deleted recurring template (AC-7.6). The server stays the authority: `delete` can still return `Failed("category_is_default")` or `Failed("category_in_use")` (for example when the partner's new expense has not synced yet), and the UI shows the same reason inline. The editor picks the text from `deletable` and `defaultKey`: default → `settings_category_delete_blocked_default_short`; in use and archived → `settings_category_delete_blocked_in_use_archived`; in use and active → `settings_category_delete_blocked_in_use`. Fallback until M12d lands: for a custom category "Ta bort kategori" is enabled and `Failed("category_in_use")` shows the in-use reason inline after the tap. Both members are implemented in `:data` and `:core:fakes` (every fake scenario computes them from its own data) by M12d. -Readings fixed in v0.10 (D-060 to D-062, as landed by M12d `bdba9d3`; no type changes): +Readings fixed in v0.10 (D-060 to D-062, as landed by M12d `557af7e`; no type changes): * **`frequentCategoryIds` holds only used categories (D-060).** Each list holds only categories actually used in that account, so it may have fewer than five entries or be empty. Filling up to five from `sort_order` and placing the selected category fifth is the editor's job (UX-9, `docs/ux/design-spec.md` §3.7), not the repository's. * **`frequentCategoryIds` keys and ties (D-061).** The map has a key for every active account (an empty list when nothing was used there) and no key for an archived account. Ties: use count descending, then `sort_order` ascending, then category id ascending, so Room and the fakes give the identical order. @@ -1991,7 +2049,7 @@ Readings fixed in v0.11 (D-069, D-073 to D-075, as landed by M7a to M7f and M6b; * **`UpdateRepository.check()` (D-073, revised by D-092 in v0.13).** ~~Returns nothing; S-22 reads `UpdateState` afterwards and treats a still-`None` state as "up to date".~~ Returns an `UpdateCheck` since v0.13 (below). * **Quick row fill (D-075).** The D-060 fill lives in `:core:design`'s `quickRowCategories(categories, frequent, selected)`; editors pass the raw `frequentCategoryIds[accountId]`. An empty or short list is filled from `sort_order`; the selected category takes the fifth place when absent. This replaces `docs/ux/design-spec.md` §3.7's interim "selected first, then `sort_order`" and the v0.9 fallback above. -Changes in v0.13 (D-086 to D-095, as landed by M12e `1470a2f`): +Changes in v0.13 (D-086 to D-095, as landed by M12e `8c60715`): * **`Pairing` (D-086).** `SessionRepository.pair` returns `OnlineResult`, `inviteKind` read from the E-3 `invite_kind`; a wire value the app does not know is read as `DEVICE`, which leads to Home. S-1 goes to S-2 only for `InviteKind.PARTNER`. Fakes: `FakeScenario.pairingInviteKind` (null: `partner` for a member, `admin_setup` for the admin). * **Admin name from the invite link (D-087).** Not a `:core:model` type: `:feature:onboarding`'s `InviteLink` reads the optional `from` parameter of both link forms into `PastedInvite.adminName`, `:app`'s `AppLink.Invite(serverUrl, code, adminName)` carries it to `PairingScreen(initialAdminName)` and `PairingViewModel.prefill(serverUrl, code, adminName)`. A name that is not a §9.6 display name (1 to 40 code points after trimming, no control characters) or not valid percent-encoding is dropped. @@ -2061,7 +2119,7 @@ Exhaustive list: | `protocol_unsupported` | 426 | Missing or unsupported `X-Kvitt-Protocol` | | `rate_limited` | 429 | §8.6, with `Retry-After` | | `internal` | 500 | Unexpected server error (logged with a request id, returned in `message`) | -| `not_implemented` | 501 | Route registered but not yet implemented (only during milestones M2a to M4); attached to no operation in `contracts/openapi.yaml` | +| `not_implemented` | 501 | Route registered but not yet implemented (only during milestones M2a to M4); attached to no operation in `contracts/openapi.yaml`. No longer produced by the server since the server overhaul (`6c405e1`); it stays in the enum and the contract | | `unavailable` | 503 | Database busy beyond `busy_timeout`, or server shutting down | Each endpoint below lists the codes it can return besides the always-possible `unauthorized`, `device_revoked`, `protocol_unsupported`, `rate_limited`, `internal`, `unavailable` (authenticated) or `rate_limited`, `internal`, `unavailable` (unauthenticated). @@ -2183,7 +2241,7 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default **E-16 `DELETE /devices/{device_id}`** (device). 204. Revoking an already revoked device is 204. Errors: `forbidden`, `not_found`, `cannot_revoke_current_device`. -**E-17 `PUT /devices/current/push-token`** (device). Request `{ "fcm_token": string? }` (the key is required, its value nullable; null clears). A string must be non-empty: `""` is `invalid_request` (D-034). 204. Errors: `invalid_request`. +**E-17 `PUT /devices/current/push-token`** (device). Request `{ "fcm_token": string? }` (the key is required, its value nullable; null clears). A string must be non-empty: `""` is `invalid_request` (D-034); more than 512 characters after trimming is `invalid_request` with field `fcm_token` too (D-114, security review L-4). 204. Errors: `invalid_request`. **E-18 `POST /accounts`** (device). Request: ```json @@ -2365,6 +2423,8 @@ Every rejection, at steps 3 and 4 too, carries `current` = the stored row with t | `reference_not_found` | Its account or category was deleted | "Its account or category no longer exists" | | `clock_skew` | Phone clock more than 10 minutes ahead | "Your phone's clock is wrong. Fix it and sync again" | +For every reason S-16 can show what was lost: `Rejection.lost` is decoded from `sync_rejections.rejected_payload` and shown read-only (§13.3 S-16, D-109). + #### 14.4.7 Audit diff Diff fields and their JSON value types: @@ -2547,7 +2607,7 @@ One file per function in `contracts/test-vectors/`: `compute_split.json`, `compu } ``` -Input keys are the snake_case parameter names of the Rust signatures. For the Kotlin-only analytics functions they are the snake_case names of the Kotlin parameters and fields: `spend_series` takes `expenses` (`[{"date", "account_id", "category_id", "amount_ore", "shares"}]`), `range` (`{"start", "end_inclusive"}`), `granularity`, `measure`, `group_by`; `resolve_range` takes `preset`, `today`, `earliest_expense`, `custom_start`, `custom_end`; `suggest_category` takes `description` (string), `history` (`[{"description", "category_id", "expense_date", "created_at"}]`), `keywords` (`[{"keyword", "match", "default_key"}]` in table order, `match` one of `token`, `prefix`, `phrase`), `active_categories` (`[{"id", "default_key"}]`, `default_key` null for a custom category) and `fallback` (category id or null), exactly as in `contracts/test-vectors/suggest_category.json` (`6b60ef7`); the `unicode` parameter has no key (the test binds the platform implementation). `spec` is `{"mode":"ratio","bp":{…}}`, `{"mode":"exact","ore":{…}}` or `{"mode":"full","bearer_user_id":"…"}`. Outputs: `compute_split` `{"ok": {user: öre}}`; `compute_balances` `{"ok": {"per_account": {...}, "global": {...}}}`; `propose_allocation` `{"ok": {"allocations": [{"account_id","amount_ore"}], "excess_ore": n, "over_settled_account_ids": [...]}}`; `validate_settlement` `{"ok": null}`; `over_settled_accounts` `{"ok": [ids]}`; `spend_series` `{"ok": {"buckets": [{"key","start","end_inclusive","totals","total"}], "group_totals": {...}, "grand_total": n}}` with `measure` input `"household"` or `{"share": "a"}`, and dates as strings; `resolve_range` `{"ok": {"start","end_inclusive"}}`; `suggest_category` `{"ok": ""}` or `{"ok": null}` (it has no error case). Errors: `{"error": ""}`. +Input keys are the snake_case parameter names of the Rust signatures. For the Kotlin-only analytics functions they are the snake_case names of the Kotlin parameters and fields: `spend_series` takes `expenses` (`[{"date", "account_id", "category_id", "amount_ore", "shares"}]`), `range` (`{"start", "end_inclusive"}`), `granularity`, `measure`, `group_by`; `resolve_range` takes `preset`, `today`, `earliest_expense`, `custom_start`, `custom_end`; `suggest_category` takes `description` (string), `history` (`[{"description", "category_id", "expense_date", "created_at"}]`), `keywords` (`[{"keyword", "match", "default_key"}]` in table order, `match` one of `token`, `prefix`, `phrase`), `active_categories` (`[{"id", "default_key"}]`, `default_key` null for a custom category) and `fallback` (category id or null), exactly as in `contracts/test-vectors/suggest_category.json` (`aa765bb`); the `unicode` parameter has no key (the test binds the platform implementation). `spec` is `{"mode":"ratio","bp":{…}}`, `{"mode":"exact","ore":{…}}` or `{"mode":"full","bearer_user_id":"…"}`. Outputs: `compute_split` `{"ok": {user: öre}}`; `compute_balances` `{"ok": {"per_account": {...}, "global": {...}}}`; `propose_allocation` `{"ok": {"allocations": [{"account_id","amount_ore"}], "excess_ore": n, "over_settled_account_ids": [...]}}`; `validate_settlement` `{"ok": null}`; `over_settled_accounts` `{"ok": [ids]}`; `spend_series` `{"ok": {"buckets": [{"key","start","end_inclusive","totals","total"}], "group_totals": {...}, "grand_total": n}}` with `measure` input `"household"` or `{"share": "a"}`, and dates as strings; `resolve_range` `{"ok": {"start","end_inclusive"}}`; `suggest_category` `{"ok": ""}` or `{"ok": null}` (it has no error case). Errors: `{"error": ""}`. Minimum cases M1 must include (more are welcome): @@ -2562,7 +2622,7 @@ Minimum cases M1 must include (more are welcome): #### 14.5.6 Category suggestion (Kotlin only) -The rule is §9.11 (D-049). Types and functions in `:domain`, package `se.hagstedt.kvitt.domain` (landed with M12a, `6b60ef7`). The records live in `:domain`, not in `:core:model`: the keyword table is data, not a UI contract, and default keys are open strings there. +The rule is §9.11 (D-049). Types and functions in `:domain`, package `se.hagstedt.kvitt.domain` (landed with M12a, `aa765bb`). The records live in `:domain`, not in `:core:model`: the keyword table is data, not a UI contract, and default keys are open strings there. ```kotlin data class SuggestionHistoryEntry(val description: String, val categoryId: CategoryId, val expenseDate: LocalDate, val createdAt: String) @@ -2659,14 +2719,14 @@ tools/ gradle-serial.sh clean-builds.sh install-git-hooks.sh git-hooks/ dep server/ Cargo.toml (workspace) Cargo.lock Dockerfile rust-toolchain.toml crates/kvitt-domain/ src/{lib.rs, split.rs, balance.rs, allocation.rs, settlement.rs, recurring.rs} tests/vectors.rs - crates/kvitt-server/ migrations/{0001_init.sql, 0002_categories_v2.sql} + crates/kvitt-server/ migrations/{0001_init.sql, 0002_categories_v2.sql, 0003_device_version_name.sql} src/{main.rs, lib.rs, config.rs, error.rs, router.rs, openapi.rs, auth.rs, time.rs} src/db/{mod.rs, pool.rs, sequence.rs} src/wire/{mod.rs, user.rs, device.rs, invite.rs, account.rs, category.rs, expense.rs, settlement.rs, recurring.rs, audit.rs, sync.rs} - src/api/{mod.rs, health.rs, setup.rs, invites.rs, landing.rs, me.rs, pictures.rs, devices.rs, accounts.rs, categories.rs, recurring.rs, proposal.rs, apk.rs, sync.rs, rate_limit.rs} - src/tasks/ src/sync/ src/audit/ src/push/ src/scheduler/ - src/cli/{mod.rs, bootstrap.rs, pair_device.rs, rotate_epoch.rs, healthcheck.rs} - tests/ + src/api/{mod.rs, health.rs, setup.rs, invites.rs, landing.rs, me.rs, pictures.rs, devices.rs, accounts.rs, categories.rs, recurring.rs, proposal.rs, apk.rs, sync.rs, rate_limit.rs, rows.rs, validate.rs} + src/tasks/ src/sync/ src/audit/ src/push/ + src/cli/{mod.rs, bootstrap.rs, pair_device.rs, rotate_epoch.rs, healthcheck.rs, invite_partner.rs} + tests/ tests/common/{mod.rs, sync_fixtures.rs} (shared harness, included through #[path] by every test binary) crates/kvitt-import/ src/ data/splitwise_categories.csv tests/fixtures/ android/ settings.gradle.kts build.gradle.kts gradle.properties gradle/{libs.versions.toml, gradle-daemon-jvm.properties, wrapper/} @@ -2690,12 +2750,12 @@ 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) | | `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's tests are one binary, `tests/core_api/`) | 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's tests are the binaries `tests/sync_api/`, `tests/push_fcm/`, `tests/scheduler_daily/`; its scheduler is `src/tasks/scheduler.rs`; there is no `src/scheduler/`) | 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 | +| `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's tests are the binaries `tests/sync_api/`, `tests/push_fcm/`, `tests/scheduler_daily/`; its scheduler is `src/tasks/scheduler.rs`; there is no `src/scheduler/`; since the server overhaul (`8cb42f8`) every test binary includes the shared harness `tests/common/mod.rs` and `tests/common/sync_fixtures.rs` through `#[path]`, replacing `tests/core_api/common.rs` and `tests/sync_api/m4.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 | | `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/fakes/**` | M2b | M7x add files only under `fakes//` | -| `android/core/design/**` | M6 (fix pass M6b, landed `3a1bd14`) | M7x may not edit; requests go through the orchestrator; later design plans | +| `android/core/design/**` | M6 (fix pass M6b, landed `b24c8b5`) | M7x may not edit; requests go through the orchestrator; later design 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/feature/onboarding/**` | M7a | M7g (feature cleanups), later feature plans | @@ -2708,10 +2768,10 @@ Ownership (an agent edits only paths its milestone owns; anything else needs the | `.gitea/workflows/**` | M2c-pi (D-023) | later infra plans | | git-ignored `infra/secrets/**` in the main checkout | M2c-dev (D-022), then M2c-pi | M8 copies `google-services.json` from it (§10.7) | | `docs/ux/**` | U1, U2 | | -| M12 (D-054): `contracts/category-keywords.csv`, `contracts/schema/migrations/**`, `contracts/test-vectors/suggest_category.json` and the other `contracts/**` edits of §11.2 row M12a, `tools/contracts-check.py`, `android/domain/**` (`suggestCategory`, `UnicodeText`) | M12a (contract-change plan, landed `6b60ef7`) | contract-change plans only | -| M12 (D-054): `contracts/openapi.yaml` (`DefaultCategoryKey`) and the category examples M12a held back; `kvitt-server` `migrations/0002_categories_v2.sql`, `src/db/mod.rs` (`SCHEMA_VERSION`), `src/api/validate.rs` (token lists), `src/wire/category.rs` and `src/wire/mod.rs` (key enum), their tests; `kvitt-import` `data/splitwise_categories.csv`, `src/categories.rs` and its tests | M12b (contract-change plan, landed `2e2ebe7`) | the usual owners | -| M12 (D-054): `android/core/model/**` (enums, icons, `ExpenseRepository`), `android/data/**` (`UnicodeText` binding, `suggestCategory`, the build-time copy of the keyword table, D-055), `android/core/fakes/**` (defaults, fake `suggestCategory`) | M12c (contract-change plan, landed `e499107`) | the usual owners | -| D-059: `android/core/model/**` (`ExpenseEditorOptions`, `CategoryRepository`), `android/data/**` (`frequentCategoryIds`, `deletable`), `android/core/fakes/**` (both, outside the `fakes//` folders) | M12d (contract-change plan, landed `bdba9d3`) | the usual owners; M7x keep their `fakes//` folders | +| M12 (D-054): `contracts/category-keywords.csv`, `contracts/schema/migrations/**`, `contracts/test-vectors/suggest_category.json` and the other `contracts/**` edits of §11.2 row M12a, `tools/contracts-check.py`, `android/domain/**` (`suggestCategory`, `UnicodeText`) | M12a (contract-change plan, landed `aa765bb`) | contract-change plans only | +| M12 (D-054): `contracts/openapi.yaml` (`DefaultCategoryKey`) and the category examples M12a held back; `kvitt-server` `migrations/0002_categories_v2.sql`, `src/db/mod.rs` (`SCHEMA_VERSION`), `src/api/validate.rs` (token lists), `src/wire/category.rs` and `src/wire/mod.rs` (key enum), their tests; `kvitt-import` `data/splitwise_categories.csv`, `src/categories.rs` and its tests | M12b (contract-change plan, landed `6eaccec`) | the usual owners | +| M12 (D-054): `android/core/model/**` (enums, icons, `ExpenseRepository`), `android/data/**` (`UnicodeText` binding, `suggestCategory`, the build-time copy of the keyword table, D-055), `android/core/fakes/**` (defaults, fake `suggestCategory`) | M12c (contract-change plan, landed `b09f322`) | the usual owners | +| D-059: `android/core/model/**` (`ExpenseEditorOptions`, `CategoryRepository`), `android/data/**` (`frequentCategoryIds`, `deletable`), `android/core/fakes/**` (both, outside the `fakes//` folders) | M12d (contract-change plan, landed `557af7e`) | the usual owners; M7x keep their `fakes//` folders | | `docs/validation/` | the milestone that names the file | | Module shells (D-004): a shell file or module created by M2a or M2b in a path owned by a later milestone holds only what makes the build and the 501 stubs work; the later milestone owns it from the moment the shell lands and writes the body. @@ -2752,17 +2812,18 @@ 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.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.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 -- ` 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). | -| 0.5 | 2026-10-17 | Decisions from the M3 (`503cee5`) and M5 (`c28c52b`) implementation reports. M3: D-030 picture version after a removal, `-removed` marker, next version = highest on disk + 1, first upload 1 (AC-4.4, §10.4, E-10); D-031 failed accept = any 4xx but 429, refused until 60 minutes after the first failure, fixed windows (AC-2.12); D-032 rate-limit keying by token hash, 401 counts against the IP, never-accepted tokens refused when the IP is over, `KVITT_TRUSTED_PROXY` re-resolved every 60 s, last `X-Forwarded-For` entry, `into_make_service_with_connect_info` added by M4 (§8.6, §10.4, §11.3); D-033 resume moves `last_generated_period` only to a period at or after `start_period` (§9.5); D-034 strings stored trimmed, transparency flattened onto white, `fcm_token` non-empty or null, revoke clears `fcm_token`, idempotent archive keeps `server_version`, new category `sort_order` over non-deleted (§8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26). M5 (renumbered: its D-030 to D-033 are D-040 to D-043): D-026 `cursor_ahead` is epoch recovery (§14.4.2); D-027 `clock_skew` gives a new `pending_mutation_id`, and the phone re-stamps `updated_at` on the next attempt (orchestrator decision, follow-up open) (§14.4.5); D-028 periodic work `kvitt-sync-periodic` (§14.4.1); D-029 `OnlineResult.Failed("network", null)` (§14.2.4); D-040 whole-request push refusals count as no progress, 429 retries, 426 stops (§14.4.5); D-041 `DataBindingsModule` (§6.2); D-042 Room tests on Robolectric, migrations from the exported JSON (§6.2); D-043 pairing status and 426 version code in DataStore (§8.2, §14.2.2). D-035 to D-039 reserved for M4. Clarifications without a decision id: control characters restricted only in `notes`, newlines accepted in names and descriptions (§8.6); landing page without download button while no APK is published, 404 page, `Referrer-Policy` and `Cache-Control` headers (AC-2.5, §13.3); generation sets the template's `updated_at` (§9.5); E-28 accepts archived accounts and categories (AC-6.4); E-14 on an accepted invite is 204 with no change; `/health` from the healthcheck counts against loopback (§8.6); M3's tests in one binary `tests/core_api/` (§14.7); sync triggers, batch and page sizes (AC-15.3, §14.4.1); 426 stops syncing until the installed version changes and `HomeState` has no update-required field (§14.2.4, §14.3.1); pictures always fetched at 512; `NeedsInstallPermission` once (AC-16.3); `Rejection.summary`, `FieldChange` display and `RecurringTemplate.draft.date` as documented in `:data` (§14.2.4); M8 contract requests (§11.3). | -| 0.6 | 2026-10-18 | Decisions from the M4 (`bd6f10a`) implementation report. D-035: a mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable fails the whole request with 400 `invalid_request`; every other shape error is a per-mutation `validation_failed` (§14.4.4 step 3). D-036: `POST /sync` with 0 mutations is 422 `batch_too_large`, the code now reads "outside 1 to 100" (§9.6, §14.3.2, §14.4.4). D-037: a delete of a never-synced row runs the full step-8 validation; every rejection carries the stored row or null as `current`, also at steps 3 and 4; step 11 keeps the stored `created_at`; pushed ids must be canonical lowercase hyphenated UUIDs (§14.4.3, §14.4.4). D-038: FCM retries are one send plus up to three retries at 1 s, 5 s, 25 s; 401 drops the cached access token and is not retried, other 4xx are not retried; retries block the single sender task up to 31 s per device (§14.6). D-039: pruning deletes `received_at` strictly older than 90 days; seam change to D-015: `tasks::start` returns the `AppState` with the real push queue and `serve` builds the router from it, `AppState::new` defaults to `SqlAuditWriter`, `NoopAuditWriter` removed, `NoopPushQueue` kept for push disabled (§6.3, §14.1, §14.4.9; PLAN-M4). Clarifications without a decision id: §14.4.4 step 8 order is field rules, domain rules, references; a negative `since` is `invalid_request` and a pull reads one page in a single read transaction (§9.6, §14.3.4, §14.4.2); push runs in one `BEGIN IMMEDIATE` with a savepoint per mutation; the push queue is bounded at 100 and drops the oldest with a warning; the FCM JWT is RS256 with `kid` = `private_key_id`, the access token cached until 5 minutes before expiry, messages data-only with string values; `UNREGISTERED` or 404 clears the device token only if unchanged; an unusable credentials file disables push like a missing one (§6.3, §14.6); the scheduler reads the injected clock at least every 30 s, runs a missed job once after a forward jump and re-plans after a backward jump of more than a day (§9.5); `rotate-epoch` prints the new epoch, exit 0 or 1 on a database error (§10.4); `serve` uses `into_make_service_with_connect_info` (D-032 fulfilled, §8.6, §11.3); `GET /i/` with an empty code is the HTML 404 (E-4); reqwest (rustls, aws-lc-rs, platform verifier) and jsonwebtoken added, so the arm64 image needs a C toolchain stage and the distroless image CA roots, to be confirmed by M2c-pi or M10; the dev image was rebuilt with them on the Mac on 2026-10-18 (§6.3, §10.3); M4's test binaries and scheduler path (§11.3, §14.7). | -| 0.7 | 2026-10-18 | Decisions from the M9 (`ab298ab`) implementation report and the importer rehearsal on the real exports. D-044: nine Swedish Splitwise category names added to the §10.8 table and to `splitwise_categories.csv` with the key of their English equivalent (Städning and Verktyg - Övrigt `utilities`, Tjänster `home`, Cykel `transport`, Flyg `travel`, Spel `entertainment`, Kläder `shopping`, Livet - Övrigt and Utbildning `other`). D-045: reconciliation: the run commits only if each user's balance of the whole account equals the sum of the file's totals rows (one per currency, converted with `--fx`); a mismatch or a missing totals row rolls back and exits 4, a dry run reports the same, an account already holding other rows rolls back, a mismatch after any `--fx` conversion is reported but accepted (§10.8, AC-17.5). D-046: CLI semantics: `--user` takes a user id or an exact unique display name; no database is created or migrated, `meta.schema_version` must be 1; a dry run runs the full transaction and rolls back; exit codes 0 ok, 1 database or file error, 2 usage, 3 cannot import, 4 reconciliation mismatch; the import key uses an existing account's stored name; an empty description or one over 1000 code points makes the row invalid; descriptions over 100 code points are cut with the full text in `notes`; settlement notes cut to 200; both nets zero is mode `full` on the owner; idempotency through the existing `import_key` columns (§10.8, AC-17.5). M9 marked landed, M11 rehearsal recorded (§11.2, §11.3). | -| 0.8 | 2026-10-18 | Decisions from the owner's UX session (`docs/ux/proposed-decisions.md` UX-1 to UX-8 on branch `UX-sprint`). D-047: direction 3 "Kalas" with the muted palette, typeface per D-020 (§9.7, §13.4). D-048: navigation stays at three destinations, an "Aktivitet" tab not taken for v1; §13.2 binding. D-049: category suggestion from the description, new §9.11 (CS-1 to CS-7: new expenses only, manual choice wins, history most often then first token, keyword table, AC-8.6 default, no marker), AC-8.6, `ExpenseRepository.suggestCategory` (§14.2.4), Kotlin-only `suggestCategory` in `:domain` with the `UnicodeText` interface bound by `:data` and `suggest_category.json` as landed by M12a (`6b60ef7`) (§14.5.5, §14.5.6), contract file `contracts/category-keywords.csv` (§14.8). D-050: S-1 display-name field on a first pairing (AC-2.4, §13.3). D-051: §9.7 hex values become the muted Kalas values from U2, token names fixed, `slate` and `amber` `TODO(U2)`. D-052: "Utgiften sparades" without undo (S-7), archived account hides the FAB (S-5), per-account settle-up keeps the swap and an editable amount (S-9), avatar tokens `avatar-1` and `avatar-2` (AC-4.5), Swedish effect copy (AC-8.10). D-053: finer Splitwise mapping (§10.8). D-054: fourteen default categories (`housing`, `insurance`, `children`, `fees` added; AC-7.2), icons `key`, `shield`, `account_balance` (33), colours `slate`, `amber` (14), server schema 2 through migration 0002 rebuilding `categories` (§14.1.1, locked as `contracts/schema/migrations/0002_categories_v2.sql`, copied from M12a's file; no `server-v2.sql`, §14.8), importer schema check 2 (§10.8), enums in §14.2.4 and §14.3.3, E-1 `schema_version` 2; Room schema unchanged. New milestone group M12 "Category contract v2" (M12a contracts, then M12b server and M12c Android in parallel; M6 and M7c wait for M12c) (§11). | -| 0.9 | 2026-10-18 | Outcomes of M12b (`2e2ebe7`) and M12c (`e499107`), U2 (landed `16c4d01`) and UX-9. D-055: the keyword table is copied from `contracts/category-keywords.csv` at build time by `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords` (AGP generated resource directory), a missing file fails the build, packaged as `se/hagstedt/kvitt/data/category-keywords.csv` (and the fakes path in debug), no committed copy, a `:data` test asserts byte identity and the §14.8 rules; supersedes the `android/data/src/main/resources` copy (§11.2 M12c, §14.2.4, §14.7, §14.8). D-056: pure `parseCategoryKeywords(csv)` in `:domain`, shared by `:data` and `:core:fakes` (§14.5.6). D-057: `DefaultCategoryKey` declared in AC-7.2 order; the Rust wire enum, the OpenAPI enum and the 0002 `CHECK` list the ten v1 keys then the four new ones; wire values unaffected (§14.2.4). D-058: test harnesses seed users after the fourteen category seeds (`server_version` 15 and 16); `E-7-me/response.json` shows 15 and 16 and its test compares them again (§14.3.5). D-051 fulfilled: §9.7 hex values from `docs/ux/design-spec.md` §2.3; `design-spec.md` is the binding design document and `strings.csv` (558 rows, D-017 prefixes, plurals as `key:one`/`key:other`) the source of string resources (§13.4). D-059: UX-9 accepted as a contract change: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable(id)` with their readings and fallbacks (§14.2.4), new milestone M12d (§11, §14.7); M7a to M7f depend on M6 and M12d. Clarifications without a decision id: `suggestCategory` history is every non-deleted expense (pending included, all accounts, both users), active categories neither archived nor deleted, fallback last-used in the account if active else `other`, null account gives `other`; when `other` is archived and no active last-used exists the result is null and the editor shows no category until the user picks one (AC-8.6, §9.11 CS-5, §14.2.4); stored `expense_date` values are valid ISO dates (§9.6), so parsing cannot fail. §11.2: M12a, M12b, M12c, U1, U2 landed, M6 started 2026-10-18; dry run of 2026-10-18 (Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled) (§11.2, §11.3). §10: development stack migrated to schema 2 on 2026-10-18 after a backup. PLAN-M12b: bundled SQLite is 3.51.x (3.51.3), not 3.50. | -| 0.10 | 2026-10-18 | Outcomes of M12d (`bdba9d3`) and M6 (`3080804`) and two owner-approved string decisions. D-060: `frequentCategoryIds` holds only categories used in the account (possibly fewer than five or empty); filling to five and placing the selected category fifth stays in the editor; not a contract change (§14.2.4). D-061: a key for every active account (empty list when unused), none for archived accounts; ties by count, `sort_order`, then category id (§14.2.4). D-062: `deletable` is false for an unknown or deleted id; archived categories follow AC-7.6; fake `delete` and `deletable` share one rule (§14.2.4). D-063: charts on Compose `Canvas` in `:core:design`, not Vico (3.3.1 cannot meet design-spec §3.10); Vico stays in the catalog unused, removable later (§6.2). D-064: Material 3 Expressive against material3 1.4.0: `ShortNavigationBar` used; `MaterialShapes`, `ButtonGroup`, `LoadingIndicator`, `LinearWavyProgressIndicator`, medium extended FAB, button sizes, segmented list items, expressive slider, `MotionScheme.expressive()` and the increased shapes take fallbacks; fulfils design-spec §6 open point 5 (§6.2). D-065: sv and en month and weekday tables in `DateFormatter`; chart labels without dots; file sizes in SI megabytes with one decimal (§9.8). D-066: golden layout per component sheet × light/dark × sv/en, plus 200 % for long states, switched through a configuration context; M7a to M7f follow it (§6.2). D-067: English default name of `restaurants` is "Dining" (AC-7.2, `strings.csv`, `:core:design`). D-068: shared `ledger_settlement_label` ("Payment" / "Betalning") for the settlement row's type label; settlement row titles may wrap to 2 lines (3 at 200 %); an empty chart range shows only the 0 baseline (§13.3, §13.4). Without a decision id: `:core:design` described as built (§6.2); `SyncStatusPill` takes a `failed` flag until `SyncSummary` gets a "last attempt failed" field (§13.3, M8 request); M6's M8 contract requests `LocalAvatarPictures`/`AvatarPictures` (authenticated E-11 request from user id and `pictureVersion`), `ProvideHousehold(me, partner)`, `LocalKvittClock`; design-spec §2.7 One UI font check at the M8 install (§11.3). §11.2: M12d and M6 landed, M7a to M7f started 2026-10-18. | -| 0.11 | 2026-10-18 | Outcomes of the M7 wave (M7a `b9b061a`, M7e `6ac711d`, M7d `082b8dc`, M7b `31760f9`, M7c `1e42f76`, M7f `a54eb7c`) and the design-system fix pass M6b (`3a1bd14`); owner approved in advance. D-069: S-11 total caption " · Hushållet" / " · N veckor", label "Totalt · Min del" for my share, whole-month ranges for month presets and days otherwise, legend and stacking by `sortOrder` and `AccountRepository.all()` order, `AnalyticsState.groups` order unspecified, loading = `lastSuccessAt == null` with skeleton cards, offline looks like loaded (§13.3, §14.2.4). D-070: S-12 week title per `strings.csv` ("Vardag, vecka 41"), whole-range title ", ", no error state on S-11/S-12, no loading state on S-12 (§13.3). D-071: top-level screens S-3, S-4, S-11, S-13 render their own `KvittNavigationBar` with `onNavigate`, no second bar in `:app`; navigation-argument keys are feature constants read through `SavedStateHandle`; result snackbars shown by the screen returned to, account-archived snackbar with undo (§13.2). D-072: a new template's day of month defaults to today (AC-9.8, S-19). D-073: S-22 "Sök efter uppdatering" reports "Du har den senaste versionen" when `UpdateState` stays `None`; network failure indistinguishable, accepted for v1 (new AC-16.6, §14.2.4, §11.3). D-074: `FieldError.field` values are the model property names; an edited settlement keeps its saved rows, "Föreslå igen" re-proposes; S-9's untouched-accounts line derives from Home balances (AC-11.6, §13.3, §14.2.4). D-075: the D-060 fill is `:core:design`'s `quickRowCategories`, replacing design-spec §3.7's interim rule (§14.2.4). D-076: category labels up to two centred lines, long custom names end in "…" (§13.3 S-7). D-077: day ranges in captions and summaries written out with the year, chip and S-12 title keep the short form (§9.8). D-078: stored amounts open for editing with both decimals, ratio percents trimmed (§9.6). D-079: shared keys `common_ok`, `common_undo`, `editor_split_full_on_you` and the M7b–M7f module keys recorded in `strings.csv` (578 rows, header included) (§13.4). D-080: "Synka nu" on S-8, S-10 and S-16 is online only; discard dialogs of S-6, S-7, S-9, S-19 have no body text (§9.9, §13.3). Without a decision id: §6.2 `:app` Hilt wiring before M8 (release binds `:data` since `e6ad858`, the debug fakes module `72f21b2` is deleted by M8) and feature screenshot tests full screen at 411 × 891 dp xhdpi; §13.3 S-1 generic help line, S-2 blank-name error, S-3 error state, S-5 non-sticky month headers and "60/40" ratios (`common_ratio`), S-16 pending rows use the shared ledger rows, "Senare" hides the update banner for the ViewModel's lifetime; §11.3 contract requests for a later contract pass (M12e). §11: M7a–M7f and M6b landed, M8 and M7g started 2026-10-18 (M8 holds D-085 to D-089, renumbered to D-081 onwards on landing); §14.7 owners after landing. | -| 0.12 | 2026-10-18 | Outcome of M8 app integration (`369e516` and `00a14a3`, gate green: BUILD SUCCESSFUL, 710 actionable tasks). D-081: both variants bind `:data` through `di/AppDataModule.kt` in `src/main` (includes `DataBindingsModule`); `DebugFakesModule` deleted; fakes only in tests and feature previews; `:app` tests on a plain `Application`; revises D-041 (§6.2, §11.3). D-082: a network security config cannot express IP ranges, so the debug config allows cleartext everywhere and `SessionRepositoryImpl.normaliseServerUrl` enforces the AC-2.8 limit; release config in `src/main`, debug override in `src/debug`, pinned-root config in `src/releasePinnedRoot/res` behind `kvitt.tls.pinnedRoot`; the debug `InsecureBaseConfiguration` lint warning is intended (§8.5, §10.5.3, AC-2.8). D-083: FCM registration by Firebase Installation ID (firebase-messaging 26.0.0, BoM 35.0.2, google-services 4.5.0): `register()` while paired and at each start, `onRegistered(fid)` sends `fcm_token` through E-17, `unregister()` on revoke, `firebase_messaging_installation_id_enabled` meta-data; no server change, `fid` field as a follow-up request (AC-13.7, §6.2, §14.6). D-084: an invite link opened while paired is ignored, never re-pairs (AC-2.6). D-085: a tap on the generic notification, a deletion or `sync_batch` opens Home; unknown type or `v` ≠ 1 only syncs; channel `activity`, notification id = hash of `entity_id`, `sync_batch` id 1; push handled by an expedited WorkManager job calling `syncForPush` (20 s) then building the text from Room (AC-13.6, §14.6). Without a decision id: the session gate (S-1, S-2, S-18; S-3 while `partner == null`) in §13.2; M8's `:app` wiring facts (`LocalKvittHousehold`, `LocalKvittClock`, `LocalAvatarPictures`, permission after the first sync, sync and update triggers, unsigned release without keys, exported components) in §11.2, §11.3, §8.8, §10.6, AC-2.9; M8's contract requests on the §11.3 M12e list (`PushEvent` to `:core:model`, S-22 licences, detail-screen snackbar hosts, S-7/S-8 snackbar strings, launcher icon, `fid`); new OQ-5 (licences screen, default not in v1). §11: M8 and M7g (`34a2590`) landed; M12e started 2026-10-18. The end-to-end pairing, offline and push test and the design-spec §2.7 typeface check on the phone are still to run. | -| 0.13 | 2026-10-18 | Outcome of the M12e contract pass (`1470a2f` contract pass, `90505a8` launcher icon; gates green: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; server 253 tests with the OpenAPI equality, vector and migration byte-identity tests). D-086: E-3 returns `invite_kind`; `SessionRepository.pair` returns `OnlineResult`, an unknown kind reads as `device`; S-2 follows only a `partner` pairing; `FakeScenario.pairingInviteKind` (AC-2.4, AC-2.10, §13.2, §13.3 S-2, §14.2.4, E-3). D-087: E-12 link `/i/?from=`; E-4 takes optional `from` and passes it only into `kvitt://invite?server&code&from`, never shown, an invalid value dropped; `InviteLink`, `AppLink.Invite`, `PairingScreen(initialAdminName)`, `PairingViewModel.prefill` carry it; S-1 shows `onboarding_code_help` with the name; links without a name still parse (AC-2.5, AC-2.6, §13.3 S-1, E-4, E-12). D-088: `SyncSummary.lastAttemptFailed`; `SyncStatusPill`, `syncPillVariant`, `syncLineText` drop `failed`; S-16 failed = online and `lastAttemptFailed` (§13.3 S-4, S-16, §14.2.4, §11.3). D-089: `Rejection.actor` from the stored `current.updated_by_user_id`, partner as fallback; no protocol change (§14.2.4, §14.4.6). D-090: optional request header `X-Kvitt-App-Version-Name` on every operation, stored at E-3 and the hourly last-seen update, blank or over 32 characters ignored; migration 0003 adds `devices.app_version_name`, server schema 3 (new §14.1.2, contract copy with byte-identity test, v2→v3 migration test); `app_version_name` on the Device wire object; `Device.appVersionName`; S-20 shows "Kvitt 1.1"; importer expects schema 3; Room unchanged at 1 (§8.2, §10, §10.8, §14.1, §14.3.1, §14.3.3, E-1, E-3, AC-3.1, §13.3 S-20). D-091: `RecurringRepository.create(NewRecurringTemplate)`, the server mints the id (§13.3 S-19, §14.2.4, E-28). D-092: `UpdateRepository.check(): UpdateCheck` (`UpToDate`, `Available`, `Failed`), 404 is up to date, debug reports `UpToDate`; S-22 up-to-date or could-not-reach-server; revises D-073 (AC-16.6, §13.3 S-22, §14.2.4). D-093: `SettleState.untouched` with nets excluding the edited settlement, empty per account; revises D-074's S-9 reading (§13.3 S-9, §14.2.4). D-094: the D-027 re-stamp as implemented: at the start of a cycle with `clock_skew` set, pending rows stamped later than now are re-stamped to now (§14.4.5). D-095: `PushEvent` in `:core:model`; six screens host the shared snackbar host, no `:app` overlay host; snackbar resource ids from S-7 and S-8; adaptive vector launcher icon (`#9C5238`, `receipt_long` at 48 dp, monochrome layer); revises D-071 (§6.2, §13.2, §13.3 S-7, S-8, §14.2.4, §14.6). Without a decision id: the 128 px picture size left unchanged by assumption (owner question open); the M12e list in §11.3 replaced by its outcome and a "Remaining contract requests" list (OQ-5 licences, `fid`); goldens re-recorded (S-20 devices; new `s1_pairing_link_admin`, `s22_about_check_failed`); compatibility note: the M12e APK needs a schema-3 server, so the server is redeployed first (§14.8); dev stack at schema 3, `up.sh --down` then `up.sh` on Podman (§10). §11: M12e landed (§11.1, §11.2). | -| 0.14 | 2026-10-18 | Follow-ups after M12e (`2b4e21c`, `c694f78`, `1e404cc`). D-096: the server sends the stored `fcm_token` (the Firebase Installation ID of D-083) in FCM HTTP v1 `message.fid`, no longer in the deprecated `message.token`; no database or wire change, the column and the E-17 field keep the name `fcm_token`; `contracts/examples/push/*.json` use `fid`; revises D-083's "no server change" (§14.6, AC-13.7, §11.3). Without a decision id: the Pi image's C stage and CA roots and an OAuth plus FCM call from the arm64 image confirmed on the Mac (`docs/spikes/pi-image-arm64.md`; 63.8 MB, cold build 48 s), running on the Pi stays M2c-pi's (§10.3); Vico removed from the version catalog (`2b4e21c`), D-063 pointer (§6.2); §11.3 remaining contract requests: only OQ-5. | -| 0.15 | 2026-10-18 | The first phone test, M8b (`f0575d3`) and owner answers. D-097: chart bars top corners 4 dp, bottom 2 dp, replacing 10/4 (§6.2, §13.4, design-spec §2.9, §3.10). D-100: no SSH from the Mac to the Pis; M2c-pi delivers the `infra/pi/` bundle the owner copies to the Kvitt Pi and runs, OQ-3 values as `.env` placeholders, M2c-pi last after the app is proven, no agent allowances on the Pis, Home Assistant, Gitea or Drive; revises D-023, keeps D-001 (§10 intro, §10.1, §10.10, §11.1, §11.2, §11.3, §12). D-101: the release keystore `kvitt-release.jks` at the repository root, git-ignored by `*.jks`, absolute path in `android/local.properties`, copy and password in the owner's password manager; revises D-024 (§10.6, §11.2 M10, §11.3). Without a decision id: the M8 end-to-end test passed on the owner's S26 against the development server (pairing, scripted partner via a second admin device, offline pending and sync, `fcm_token` and `app_version_name` registered, push by `fid` delivered and tapped); M8b fixes (invite-code `VisualTransformation`, quick-row badge above the ripple clip, `KvittTopAppBar` status-bar inset, 221 goldens) and `FcmMessageBody.fid` (`09f474f`) (§11.2 M8, §11.3); OQ-5 answered: licences button hidden in v1 (M8c); the 128 px picture size not needed (§14.2.4); the "boende" keyword keeps suggesting `travel` and the account page split bar stays as designed (no change). | -| 0.16 | 2026-10-18 | M8c (`b481fab`, `64f5706`, `7c453d8`), the R8 release build (`57f984f`) and M8d (`3c68594`). D-098: goldens in three variants `_sv_light`, `_sv_dark`, `_en_light` plus `_sv_light_200`, rendered at 1x through `w411dp-hdp-mdpi`, every committed PNG compressed with `oxipng -o 4 --strip safe` (1143 PNGs / 131 MB to 865 / 26 MB, mock-ups 36.8 to 26.0 MB); revises D-066 (§6.2, §13.4). D-099: S-22 has no open-source licences row, `onOpenLicences` and `settings_about_licences` removed, `strings.csv` 577 rows; closes OQ-5 (§11.3, §12, §13.3, §13.4, design-spec S-22). D-102: S-7 options as four fixed full-width rows (Konto, Datum, Betalt av, Delning), `EditorOptionRows` in `:core:design`; connected buttons 8 dp side padding (§13.3, §13.4, design-spec §3.7, §3.10). Without a decision id: release builds R8-shrunk, optimised and obfuscated, APK about 11 MB (was 43 MB), `mapping.txt` kept beside the APK; release signing verified (alias `kvitt`, RSA 4096, v2) (§10.6); §11.1 graph. | -| 0.17 | 2026-10-18 | The M2c-pi bundle (`5e027df`), M8e (`15397e4`), M8f (`ed1b130`), the chips cleanup (`4755fe0`) and the Dockerfile `CARGO_BUILD_JOBS` argument (`965bb5d`). D-103: the `infra/pi/` bundle (`compose.yaml`, was `infra/compose.yaml`; `.env.example`, was `infra/env.example`; `Caddyfile`; `install.sh`, `deploy.sh [--skip-backup]` with a backup before every deploy and `/opt/kvitt/previous-tag`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`), the APK path `infra/publish-apk.sh` → `infra/pi/out/apk/` → `/opt/kvitt/apk/` → `install-apk.sh` into the `data` volume, mappings outside the volume, `.env` keys `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN`, `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`, gate `docker compose -f infra/pi/compose.yaml config`; fulfils D-100 (§10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2, §14.7). D-104: images `${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2`, the Gitea workflow `.gitea/workflows/build-images.yaml` (runner `host`, secret `kvitt_cicd`, `v1.0.N` plus `latest`, git tag pushed, `CARGO_BUILD_JOBS=1`), `infra/build-images.sh` as the Mac fallback, supercronic instead of busybox crond, `openssl` in cert-sync, pinned versions and Docker Engine from Docker's Debian arm64 repository (§10.1, §10.2, §10.3, §12 OQ-1). D-105: `backup` runs as uid 10001 on the Pi, `rclone.conf` 0600 owned 10001:10001, a temporary copy for refreshed tokens, volume owners set once by `install.sh`; revises D-023 for `rclone.conf` (§10.2). D-106: cert-sync always requests the `tar` stream (both `authorized_keys` variants), refuses a mismatched key or an earlier-expiring certificate, an unreachable admin API at first start is no failure; backup uploads every pending file, confirms each by name and size, deletes and writes `last-success` only after all are confirmed (§10.5.1, §10.9). D-107: S-19's account, payer and split as three fixed `EditorOptionRows` (Konto, Betalt av, Delning), extending D-102 (§13.3, design-spec §3.7, S-19). D-108: navigation is instant, no enter, exit, pop or size transition on the app's `NavHost` (§13.2, design-spec §2.11). Without a decision id: the Drive token, the cert-sync key and the Gitea secret `kvitt_cicd` exist; `server/Dockerfile` honours `CARGO_BUILD_JOBS` (§10.3); the unused `AccountChip`, `PayerChip`, `SplitChip` removed (design-spec §3.7); Caddy's `Authorization` redaction verified (§10.5.2); APKs about 11 MB (§10.10); §11.1 graph. | +| 0.4 | 2026-10-17 | Decisions from the M2a (`06f48ef`) and M2b (`c277ee3`) 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 -- ` 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). | +| 0.5 | 2026-10-17 | Decisions from the M3 (`7869ec4`) and M5 (`8f0e156`) implementation reports. M3: D-030 picture version after a removal, `-removed` marker, next version = highest on disk + 1, first upload 1 (AC-4.4, §10.4, E-10); D-031 failed accept = any 4xx but 429, refused until 60 minutes after the first failure, fixed windows (AC-2.12); D-032 rate-limit keying by token hash, 401 counts against the IP, never-accepted tokens refused when the IP is over, `KVITT_TRUSTED_PROXY` re-resolved every 60 s, last `X-Forwarded-For` entry, `into_make_service_with_connect_info` added by M4 (§8.6, §10.4, §11.3); D-033 resume moves `last_generated_period` only to a period at or after `start_period` (§9.5); D-034 strings stored trimmed, transparency flattened onto white, `fcm_token` non-empty or null, revoke clears `fcm_token`, idempotent archive keeps `server_version`, new category `sort_order` over non-deleted (§8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26). M5 (renumbered: its D-030 to D-033 are D-040 to D-043): D-026 `cursor_ahead` is epoch recovery (§14.4.2); D-027 `clock_skew` gives a new `pending_mutation_id`, and the phone re-stamps `updated_at` on the next attempt (orchestrator decision, follow-up open) (§14.4.5); D-028 periodic work `kvitt-sync-periodic` (§14.4.1); D-029 `OnlineResult.Failed("network", null)` (§14.2.4); D-040 whole-request push refusals count as no progress, 429 retries, 426 stops (§14.4.5); D-041 `DataBindingsModule` (§6.2); D-042 Room tests on Robolectric, migrations from the exported JSON (§6.2); D-043 pairing status and 426 version code in DataStore (§8.2, §14.2.2). D-035 to D-039 reserved for M4. Clarifications without a decision id: control characters restricted only in `notes`, newlines accepted in names and descriptions (§8.6); landing page without download button while no APK is published, 404 page, `Referrer-Policy` and `Cache-Control` headers (AC-2.5, §13.3); generation sets the template's `updated_at` (§9.5); E-28 accepts archived accounts and categories (AC-6.4); E-14 on an accepted invite is 204 with no change; `/health` from the healthcheck counts against loopback (§8.6); M3's tests in one binary `tests/core_api/` (§14.7); sync triggers, batch and page sizes (AC-15.3, §14.4.1); 426 stops syncing until the installed version changes and `HomeState` has no update-required field (§14.2.4, §14.3.1); pictures always fetched at 512; `NeedsInstallPermission` once (AC-16.3); `Rejection.summary`, `FieldChange` display and `RecurringTemplate.draft.date` as documented in `:data` (§14.2.4); M8 contract requests (§11.3). | +| 0.6 | 2026-10-18 | Decisions from the M4 (`15056dc`) implementation report. D-035: a mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable fails the whole request with 400 `invalid_request`; every other shape error is a per-mutation `validation_failed` (§14.4.4 step 3). D-036: `POST /sync` with 0 mutations is 422 `batch_too_large`, the code now reads "outside 1 to 100" (§9.6, §14.3.2, §14.4.4). D-037: a delete of a never-synced row runs the full step-8 validation; every rejection carries the stored row or null as `current`, also at steps 3 and 4; step 11 keeps the stored `created_at`; pushed ids must be canonical lowercase hyphenated UUIDs (§14.4.3, §14.4.4). D-038: FCM retries are one send plus up to three retries at 1 s, 5 s, 25 s; 401 drops the cached access token and is not retried, other 4xx are not retried; retries block the single sender task up to 31 s per device (§14.6). D-039: pruning deletes `received_at` strictly older than 90 days; seam change to D-015: `tasks::start` returns the `AppState` with the real push queue and `serve` builds the router from it, `AppState::new` defaults to `SqlAuditWriter`, `NoopAuditWriter` removed, `NoopPushQueue` kept for push disabled (§6.3, §14.1, §14.4.9; PLAN-M4). Clarifications without a decision id: §14.4.4 step 8 order is field rules, domain rules, references; a negative `since` is `invalid_request` and a pull reads one page in a single read transaction (§9.6, §14.3.4, §14.4.2); push runs in one `BEGIN IMMEDIATE` with a savepoint per mutation; the push queue is bounded at 100 and drops the oldest with a warning; the FCM JWT is RS256 with `kid` = `private_key_id`, the access token cached until 5 minutes before expiry, messages data-only with string values; `UNREGISTERED` or 404 clears the device token only if unchanged; an unusable credentials file disables push like a missing one (§6.3, §14.6); the scheduler reads the injected clock at least every 30 s, runs a missed job once after a forward jump and re-plans after a backward jump of more than a day (§9.5); `rotate-epoch` prints the new epoch, exit 0 or 1 on a database error (§10.4); `serve` uses `into_make_service_with_connect_info` (D-032 fulfilled, §8.6, §11.3); `GET /i/` with an empty code is the HTML 404 (E-4); reqwest (rustls, aws-lc-rs, platform verifier) and jsonwebtoken added, so the arm64 image needs a C toolchain stage and the distroless image CA roots, to be confirmed by M2c-pi or M10; the dev image was rebuilt with them on the Mac on 2026-10-18 (§6.3, §10.3); M4's test binaries and scheduler path (§11.3, §14.7). | +| 0.7 | 2026-10-18 | Decisions from the M9 (`50339ae`) implementation report and the importer rehearsal on the real exports. D-044: nine Swedish Splitwise category names added to the §10.8 table and to `splitwise_categories.csv` with the key of their English equivalent (Städning and Verktyg - Övrigt `utilities`, Tjänster `home`, Cykel `transport`, Flyg `travel`, Spel `entertainment`, Kläder `shopping`, Livet - Övrigt and Utbildning `other`). D-045: reconciliation: the run commits only if each user's balance of the whole account equals the sum of the file's totals rows (one per currency, converted with `--fx`); a mismatch or a missing totals row rolls back and exits 4, a dry run reports the same, an account already holding other rows rolls back, a mismatch after any `--fx` conversion is reported but accepted (§10.8, AC-17.5). D-046: CLI semantics: `--user` takes a user id or an exact unique display name; no database is created or migrated, `meta.schema_version` must be 1; a dry run runs the full transaction and rolls back; exit codes 0 ok, 1 database or file error, 2 usage, 3 cannot import, 4 reconciliation mismatch; the import key uses an existing account's stored name; an empty description or one over 1000 code points makes the row invalid; descriptions over 100 code points are cut with the full text in `notes`; settlement notes cut to 200; both nets zero is mode `full` on the owner; idempotency through the existing `import_key` columns (§10.8, AC-17.5). M9 marked landed, M11 rehearsal recorded (§11.2, §11.3). | +| 0.8 | 2026-10-18 | Decisions from the owner's UX session (`docs/ux/proposed-decisions.md` UX-1 to UX-8 on branch `UX-sprint`). D-047: direction 3 "Kalas" with the muted palette, typeface per D-020 (§9.7, §13.4). D-048: navigation stays at three destinations, an "Aktivitet" tab not taken for v1; §13.2 binding. D-049: category suggestion from the description, new §9.11 (CS-1 to CS-7: new expenses only, manual choice wins, history most often then first token, keyword table, AC-8.6 default, no marker), AC-8.6, `ExpenseRepository.suggestCategory` (§14.2.4), Kotlin-only `suggestCategory` in `:domain` with the `UnicodeText` interface bound by `:data` and `suggest_category.json` as landed by M12a (`aa765bb`) (§14.5.5, §14.5.6), contract file `contracts/category-keywords.csv` (§14.8). D-050: S-1 display-name field on a first pairing (AC-2.4, §13.3). D-051: §9.7 hex values become the muted Kalas values from U2, token names fixed, `slate` and `amber` `TODO(U2)`. D-052: "Utgiften sparades" without undo (S-7), archived account hides the FAB (S-5), per-account settle-up keeps the swap and an editable amount (S-9), avatar tokens `avatar-1` and `avatar-2` (AC-4.5), Swedish effect copy (AC-8.10). D-053: finer Splitwise mapping (§10.8). D-054: fourteen default categories (`housing`, `insurance`, `children`, `fees` added; AC-7.2), icons `key`, `shield`, `account_balance` (33), colours `slate`, `amber` (14), server schema 2 through migration 0002 rebuilding `categories` (§14.1.1, locked as `contracts/schema/migrations/0002_categories_v2.sql`, copied from M12a's file; no `server-v2.sql`, §14.8), importer schema check 2 (§10.8), enums in §14.2.4 and §14.3.3, E-1 `schema_version` 2; Room schema unchanged. New milestone group M12 "Category contract v2" (M12a contracts, then M12b server and M12c Android in parallel; M6 and M7c wait for M12c) (§11). | +| 0.9 | 2026-10-18 | Outcomes of M12b (`6eaccec`) and M12c (`b09f322`), U2 (landed `6da6b1b`) and UX-9. D-055: the keyword table is copied from `contracts/category-keywords.csv` at build time by `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords` (AGP generated resource directory), a missing file fails the build, packaged as `se/hagstedt/kvitt/data/category-keywords.csv` (and the fakes path in debug), no committed copy, a `:data` test asserts byte identity and the §14.8 rules; supersedes the `android/data/src/main/resources` copy (§11.2 M12c, §14.2.4, §14.7, §14.8). D-056: pure `parseCategoryKeywords(csv)` in `:domain`, shared by `:data` and `:core:fakes` (§14.5.6). D-057: `DefaultCategoryKey` declared in AC-7.2 order; the Rust wire enum, the OpenAPI enum and the 0002 `CHECK` list the ten v1 keys then the four new ones; wire values unaffected (§14.2.4). D-058: test harnesses seed users after the fourteen category seeds (`server_version` 15 and 16); `E-7-me/response.json` shows 15 and 16 and its test compares them again (§14.3.5). D-051 fulfilled: §9.7 hex values from `docs/ux/design-spec.md` §2.3; `design-spec.md` is the binding design document and `strings.csv` (558 rows, D-017 prefixes, plurals as `key:one`/`key:other`) the source of string resources (§13.4). D-059: UX-9 accepted as a contract change: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable(id)` with their readings and fallbacks (§14.2.4), new milestone M12d (§11, §14.7); M7a to M7f depend on M6 and M12d. Clarifications without a decision id: `suggestCategory` history is every non-deleted expense (pending included, all accounts, both users), active categories neither archived nor deleted, fallback last-used in the account if active else `other`, null account gives `other`; when `other` is archived and no active last-used exists the result is null and the editor shows no category until the user picks one (AC-8.6, §9.11 CS-5, §14.2.4); stored `expense_date` values are valid ISO dates (§9.6), so parsing cannot fail. §11.2: M12a, M12b, M12c, U1, U2 landed, M6 started 2026-10-18; dry run of 2026-10-18 (Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled) (§11.2, §11.3). §10: development stack migrated to schema 2 on 2026-10-18 after a backup. PLAN-M12b: bundled SQLite is 3.51.x (3.51.3), not 3.50. | +| 0.10 | 2026-10-18 | Outcomes of M12d (`557af7e`) and M6 (`7730a31`) and two owner-approved string decisions. D-060: `frequentCategoryIds` holds only categories used in the account (possibly fewer than five or empty); filling to five and placing the selected category fifth stays in the editor; not a contract change (§14.2.4). D-061: a key for every active account (empty list when unused), none for archived accounts; ties by count, `sort_order`, then category id (§14.2.4). D-062: `deletable` is false for an unknown or deleted id; archived categories follow AC-7.6; fake `delete` and `deletable` share one rule (§14.2.4). D-063: charts on Compose `Canvas` in `:core:design`, not Vico (3.3.1 cannot meet design-spec §3.10); Vico stays in the catalog unused, removable later (§6.2). D-064: Material 3 Expressive against material3 1.4.0: `ShortNavigationBar` used; `MaterialShapes`, `ButtonGroup`, `LoadingIndicator`, `LinearWavyProgressIndicator`, medium extended FAB, button sizes, segmented list items, expressive slider, `MotionScheme.expressive()` and the increased shapes take fallbacks; fulfils design-spec §6 open point 5 (§6.2). D-065: sv and en month and weekday tables in `DateFormatter`; chart labels without dots; file sizes in SI megabytes with one decimal (§9.8). D-066: golden layout per component sheet × light/dark × sv/en, plus 200 % for long states, switched through a configuration context; M7a to M7f follow it (§6.2). D-067: English default name of `restaurants` is "Dining" (AC-7.2, `strings.csv`, `:core:design`). D-068: shared `ledger_settlement_label` ("Payment" / "Betalning") for the settlement row's type label; settlement row titles may wrap to 2 lines (3 at 200 %); an empty chart range shows only the 0 baseline (§13.3, §13.4). Without a decision id: `:core:design` described as built (§6.2); `SyncStatusPill` takes a `failed` flag until `SyncSummary` gets a "last attempt failed" field (§13.3, M8 request); M6's M8 contract requests `LocalAvatarPictures`/`AvatarPictures` (authenticated E-11 request from user id and `pictureVersion`), `ProvideHousehold(me, partner)`, `LocalKvittClock`; design-spec §2.7 One UI font check at the M8 install (§11.3). §11.2: M12d and M6 landed, M7a to M7f started 2026-10-18. | +| 0.11 | 2026-10-18 | Outcomes of the M7 wave (M7a `1ea752c`, M7e `ea7d494`, M7d `7380e50`, M7b `0e1d99d`, M7c `0b140aa`, M7f `c4ab35f`) and the design-system fix pass M6b (`b24c8b5`); owner approved in advance. D-069: S-11 total caption " · Hushållet" / " · N veckor", label "Totalt · Min del" for my share, whole-month ranges for month presets and days otherwise, legend and stacking by `sortOrder` and `AccountRepository.all()` order, `AnalyticsState.groups` order unspecified, loading = `lastSuccessAt == null` with skeleton cards, offline looks like loaded (§13.3, §14.2.4). D-070: S-12 week title per `strings.csv` ("Vardag, vecka 41"), whole-range title ", ", no error state on S-11/S-12, no loading state on S-12 (§13.3). D-071: top-level screens S-3, S-4, S-11, S-13 render their own `KvittNavigationBar` with `onNavigate`, no second bar in `:app`; navigation-argument keys are feature constants read through `SavedStateHandle`; result snackbars shown by the screen returned to, account-archived snackbar with undo (§13.2). D-072: a new template's day of month defaults to today (AC-9.8, S-19). D-073: S-22 "Sök efter uppdatering" reports "Du har den senaste versionen" when `UpdateState` stays `None`; network failure indistinguishable, accepted for v1 (new AC-16.6, §14.2.4, §11.3). D-074: `FieldError.field` values are the model property names; an edited settlement keeps its saved rows, "Föreslå igen" re-proposes; S-9's untouched-accounts line derives from Home balances (AC-11.6, §13.3, §14.2.4). D-075: the D-060 fill is `:core:design`'s `quickRowCategories`, replacing design-spec §3.7's interim rule (§14.2.4). D-076: category labels up to two centred lines, long custom names end in "…" (§13.3 S-7). D-077: day ranges in captions and summaries written out with the year, chip and S-12 title keep the short form (§9.8). D-078: stored amounts open for editing with both decimals, ratio percents trimmed (§9.6). D-079: shared keys `common_ok`, `common_undo`, `editor_split_full_on_you` and the M7b–M7f module keys recorded in `strings.csv` (578 rows, header included) (§13.4). D-080: "Synka nu" on S-8, S-10 and S-16 is online only; discard dialogs of S-6, S-7, S-9, S-19 have no body text (§9.9, §13.3). Without a decision id: §6.2 `:app` Hilt wiring before M8 (release binds `:data` since `6453932`, the debug fakes module `a41f240` is deleted by M8) and feature screenshot tests full screen at 411 × 891 dp xhdpi; §13.3 S-1 generic help line, S-2 blank-name error, S-3 error state, S-5 non-sticky month headers and "60/40" ratios (`common_ratio`), S-16 pending rows use the shared ledger rows, "Senare" hides the update banner for the ViewModel's lifetime; §11.3 contract requests for a later contract pass (M12e). §11: M7a–M7f and M6b landed, M8 and M7g started 2026-10-18 (M8 holds D-085 to D-089, renumbered to D-081 onwards on landing); §14.7 owners after landing. | +| 0.12 | 2026-10-18 | Outcome of M8 app integration (`cb832a7` and `3d9eea8`, gate green: BUILD SUCCESSFUL, 710 actionable tasks). D-081: both variants bind `:data` through `di/AppDataModule.kt` in `src/main` (includes `DataBindingsModule`); `DebugFakesModule` deleted; fakes only in tests and feature previews; `:app` tests on a plain `Application`; revises D-041 (§6.2, §11.3). D-082: a network security config cannot express IP ranges, so the debug config allows cleartext everywhere and `SessionRepositoryImpl.normaliseServerUrl` enforces the AC-2.8 limit; release config in `src/main`, debug override in `src/debug`, pinned-root config in `src/releasePinnedRoot/res` behind `kvitt.tls.pinnedRoot`; the debug `InsecureBaseConfiguration` lint warning is intended (§8.5, §10.5.3, AC-2.8). D-083: FCM registration by Firebase Installation ID (firebase-messaging 26.0.0, BoM 35.0.2, google-services 4.5.0): `register()` while paired and at each start, `onRegistered(fid)` sends `fcm_token` through E-17, `unregister()` on revoke, `firebase_messaging_installation_id_enabled` meta-data; no server change, `fid` field as a follow-up request (AC-13.7, §6.2, §14.6). D-084: an invite link opened while paired is ignored, never re-pairs (AC-2.6). D-085: a tap on the generic notification, a deletion or `sync_batch` opens Home; unknown type or `v` ≠ 1 only syncs; channel `activity`, notification id = hash of `entity_id`, `sync_batch` id 1; push handled by an expedited WorkManager job calling `syncForPush` (20 s) then building the text from Room (AC-13.6, §14.6). Without a decision id: the session gate (S-1, S-2, S-18; S-3 while `partner == null`) in §13.2; M8's `:app` wiring facts (`LocalKvittHousehold`, `LocalKvittClock`, `LocalAvatarPictures`, permission after the first sync, sync and update triggers, unsigned release without keys, exported components) in §11.2, §11.3, §8.8, §10.6, AC-2.9; M8's contract requests on the §11.3 M12e list (`PushEvent` to `:core:model`, S-22 licences, detail-screen snackbar hosts, S-7/S-8 snackbar strings, launcher icon, `fid`); new OQ-5 (licences screen, default not in v1). §11: M8 and M7g (`3dda414`) landed; M12e started 2026-10-18. The end-to-end pairing, offline and push test and the design-spec §2.7 typeface check on the phone are still to run. | +| 0.13 | 2026-10-18 | Outcome of the M12e contract pass (`8c60715` contract pass, `b7b7483` launcher icon; gates green: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; server 253 tests with the OpenAPI equality, vector and migration byte-identity tests). D-086: E-3 returns `invite_kind`; `SessionRepository.pair` returns `OnlineResult`, an unknown kind reads as `device`; S-2 follows only a `partner` pairing; `FakeScenario.pairingInviteKind` (AC-2.4, AC-2.10, §13.2, §13.3 S-2, §14.2.4, E-3). D-087: E-12 link `/i/?from=`; E-4 takes optional `from` and passes it only into `kvitt://invite?server&code&from`, never shown, an invalid value dropped; `InviteLink`, `AppLink.Invite`, `PairingScreen(initialAdminName)`, `PairingViewModel.prefill` carry it; S-1 shows `onboarding_code_help` with the name; links without a name still parse (AC-2.5, AC-2.6, §13.3 S-1, E-4, E-12). D-088: `SyncSummary.lastAttemptFailed`; `SyncStatusPill`, `syncPillVariant`, `syncLineText` drop `failed`; S-16 failed = online and `lastAttemptFailed` (§13.3 S-4, S-16, §14.2.4, §11.3). D-089: `Rejection.actor` from the stored `current.updated_by_user_id`, partner as fallback; no protocol change (§14.2.4, §14.4.6). D-090: optional request header `X-Kvitt-App-Version-Name` on every operation, stored at E-3 and the hourly last-seen update, blank or over 32 characters ignored; migration 0003 adds `devices.app_version_name`, server schema 3 (new §14.1.2, contract copy with byte-identity test, v2→v3 migration test); `app_version_name` on the Device wire object; `Device.appVersionName`; S-20 shows "Kvitt 1.1"; importer expects schema 3; Room unchanged at 1 (§8.2, §10, §10.8, §14.1, §14.3.1, §14.3.3, E-1, E-3, AC-3.1, §13.3 S-20). D-091: `RecurringRepository.create(NewRecurringTemplate)`, the server mints the id (§13.3 S-19, §14.2.4, E-28). D-092: `UpdateRepository.check(): UpdateCheck` (`UpToDate`, `Available`, `Failed`), 404 is up to date, debug reports `UpToDate`; S-22 up-to-date or could-not-reach-server; revises D-073 (AC-16.6, §13.3 S-22, §14.2.4). D-093: `SettleState.untouched` with nets excluding the edited settlement, empty per account; revises D-074's S-9 reading (§13.3 S-9, §14.2.4). D-094: the D-027 re-stamp as implemented: at the start of a cycle with `clock_skew` set, pending rows stamped later than now are re-stamped to now (§14.4.5). D-095: `PushEvent` in `:core:model`; six screens host the shared snackbar host, no `:app` overlay host; snackbar resource ids from S-7 and S-8; adaptive vector launcher icon (`#9C5238`, `receipt_long` at 48 dp, monochrome layer); revises D-071 (§6.2, §13.2, §13.3 S-7, S-8, §14.2.4, §14.6). Without a decision id: the 128 px picture size left unchanged by assumption (owner question open); the M12e list in §11.3 replaced by its outcome and a "Remaining contract requests" list (OQ-5 licences, `fid`); goldens re-recorded (S-20 devices; new `s1_pairing_link_admin`, `s22_about_check_failed`); compatibility note: the M12e APK needs a schema-3 server, so the server is redeployed first (§14.8); dev stack at schema 3, `up.sh --down` then `up.sh` on Podman (§10). §11: M12e landed (§11.1, §11.2). | +| 0.14 | 2026-10-18 | Follow-ups after M12e (`a312758`, `f56dcbe`, `3e6c337`). D-096: the server sends the stored `fcm_token` (the Firebase Installation ID of D-083) in FCM HTTP v1 `message.fid`, no longer in the deprecated `message.token`; no database or wire change, the column and the E-17 field keep the name `fcm_token`; `contracts/examples/push/*.json` use `fid`; revises D-083's "no server change" (§14.6, AC-13.7, §11.3). Without a decision id: the Pi image's C stage and CA roots and an OAuth plus FCM call from the arm64 image confirmed on the Mac (`docs/spikes/pi-image-arm64.md`; 63.8 MB, cold build 48 s), running on the Pi stays M2c-pi's (§10.3); Vico removed from the version catalog (`a312758`), D-063 pointer (§6.2); §11.3 remaining contract requests: only OQ-5. | +| 0.15 | 2026-10-18 | The first phone test, M8b (`328c466`) and owner answers. D-097: chart bars top corners 4 dp, bottom 2 dp, replacing 10/4 (§6.2, §13.4, design-spec §2.9, §3.10). D-100: no SSH from the Mac to the Pis; M2c-pi delivers the `infra/pi/` bundle the owner copies to the Kvitt Pi and runs, OQ-3 values as `.env` placeholders, M2c-pi last after the app is proven, no agent allowances on the Pis, Home Assistant, Gitea or Drive; revises D-023, keeps D-001 (§10 intro, §10.1, §10.10, §11.1, §11.2, §11.3, §12). D-101: the release keystore `kvitt-release.jks` at the repository root, git-ignored by `*.jks`, absolute path in `android/local.properties`, copy and password in the owner's password manager; revises D-024 (§10.6, §11.2 M10, §11.3). Without a decision id: the M8 end-to-end test passed on the owner's S26 against the development server (pairing, scripted partner via a second admin device, offline pending and sync, `fcm_token` and `app_version_name` registered, push by `fid` delivered and tapped); M8b fixes (invite-code `VisualTransformation`, quick-row badge above the ripple clip, `KvittTopAppBar` status-bar inset, 221 goldens) and `FcmMessageBody.fid` (`6eb3f85`) (§11.2 M8, §11.3); OQ-5 answered: licences button hidden in v1 (M8c); the 128 px picture size not needed (§14.2.4); the "boende" keyword keeps suggesting `travel` and the account page split bar stays as designed (no change). | +| 0.16 | 2026-10-18 | M8c (`75b549f`, `a0c25b0`, `7c453d8`), the R8 release build (`c30a49e`) and M8d (`bacbd18`). D-098: goldens in three variants `_sv_light`, `_sv_dark`, `_en_light` plus `_sv_light_200`, rendered at 1x through `w411dp-hdp-mdpi`, every committed PNG compressed with `oxipng -o 4 --strip safe` (1143 PNGs / 131 MB to 865 / 26 MB, mock-ups 36.8 to 26.0 MB); revises D-066 (§6.2, §13.4). D-099: S-22 has no open-source licences row, `onOpenLicences` and `settings_about_licences` removed, `strings.csv` 577 rows; closes OQ-5 (§11.3, §12, §13.3, §13.4, design-spec S-22). D-102: S-7 options as four fixed full-width rows (Konto, Datum, Betalt av, Delning), `EditorOptionRows` in `:core:design`; connected buttons 8 dp side padding (§13.3, §13.4, design-spec §3.7, §3.10). Without a decision id: release builds R8-shrunk, optimised and obfuscated, APK about 11 MB (was 43 MB), `mapping.txt` kept beside the APK; release signing verified (alias `kvitt`, RSA 4096, v2) (§10.6); §11.1 graph. | +| 0.17 | 2026-10-18 | The M2c-pi bundle (`2476667`), M8e (`a59abeb`), M8f (`956908a`), the chips cleanup (`1328afe`) and the Dockerfile `CARGO_BUILD_JOBS` argument (`b4d9467`). D-103: the `infra/pi/` bundle (`compose.yaml`, was `infra/compose.yaml`; `.env.example`, was `infra/env.example`; `Caddyfile`; `install.sh`, `deploy.sh [--skip-backup]` with a backup before every deploy and `/opt/kvitt/previous-tag`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`), the APK path `infra/publish-apk.sh` → `infra/pi/out/apk/` → `/opt/kvitt/apk/` → `install-apk.sh` into the `data` volume, mappings outside the volume, `.env` keys `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN`, `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`, gate `docker compose -f infra/pi/compose.yaml config`; fulfils D-100 (§10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2, §14.7). D-104: images `${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2`, the Gitea workflow `.gitea/workflows/build-images.yaml` (runner `host`, secret `kvitt_cicd`, `v1.0.N` plus `latest`, git tag pushed, `CARGO_BUILD_JOBS=1`), `infra/build-images.sh` as the Mac fallback, supercronic instead of busybox crond, `openssl` in cert-sync, pinned versions and Docker Engine from Docker's Debian arm64 repository (§10.1, §10.2, §10.3, §12 OQ-1). D-105: `backup` runs as uid 10001 on the Pi, `rclone.conf` 0600 owned 10001:10001, a temporary copy for refreshed tokens, volume owners set once by `install.sh`; revises D-023 for `rclone.conf` (§10.2). D-106: cert-sync always requests the `tar` stream (both `authorized_keys` variants), refuses a mismatched key or an earlier-expiring certificate, an unreachable admin API at first start is no failure; backup uploads every pending file, confirms each by name and size, deletes and writes `last-success` only after all are confirmed (§10.5.1, §10.9). D-107: S-19's account, payer and split as three fixed `EditorOptionRows` (Konto, Betalt av, Delning), extending D-102 (§13.3, design-spec §3.7, S-19). D-108: navigation is instant, no enter, exit, pop or size transition on the app's `NavHost` (§13.2, design-spec §2.11). Without a decision id: the Drive token, the cert-sync key and the Gitea secret `kvitt_cicd` exist; `server/Dockerfile` honours `CARGO_BUILD_JOBS` (§10.3); the unused `AccountChip`, `PayerChip`, `SplitChip` removed (design-spec §3.7); Caddy's `Authorization` redaction verified (§10.5.2); APKs about 11 MB (§10.10); §11.1 graph. | +| 0.18 | 2026-10-18 | M12f (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`), the server gap pass (`8660b7f`), the server overhaul (`8103dae` to `b985ce9`), the infra overhaul (`2fac1ec` to `e70fe0d`) and infra security fixes (`7fb86f5`), the AC-coverage audit and the security review. D-109: S-16 shows a rejected change's lost version read-only, `Rejection.lost: RejectedVersion?` with `RejectedVersion(row, accountName, payer, mode, shares, notes, deleted)` from `rejected_payload`, null when undecodable or a user or the category is missing; dialog "Din version", "Du tog bort den här", "Visa nuvarande version"; ledger rows take a null `onClick` (read-only); four `home_sync_lost_*` strings (§13.3 S-16, §14.2.4, §14.4.6, design-spec S-16). D-110: `AboutState.schemaVersion`; S-22 reads `/health` when it opens online and shows " (schema )" (`settings_about_server_version_schema`), header fallback offline (§13.3 S-22, §14.2.4, E-1, design-spec S-22). D-111: `SettlementRepository.start` and `recompute` return `SettleState?` (null without a session or partner), `save` returns `Invalid(payer, validation_failed)`; no throws (§14.2.4, AC-5.1). D-112: a failed E-17 send is retried after each successful sync until accepted, a newer FID replaces it (AC-13.7). D-113: `kvitt-server invite-partner` (0 ok, 2 household full, 3 no admin) and the `push sent` info line (§8.7, §10.4); `tools/contracts-check.py` 134 checks. D-116: notifications `VISIBILITY_PRIVATE` with the generic public version (new AC-13.9); L-6 kept as built, S-1's server row expanded for a non-default server, the confirm step listed as OQ-6 (AC-2.6, AC-2.8, §12). D-117: Caddy log redaction of `/i/` and query strings, `Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` (§8.7, §10.5.2, which now quotes `infra/pi/Caddyfile` at `7fb86f5`); Caddy as uid 10002 with group 1000, read-only, `cap_drop ALL` plus `NET_BIND_SERVICE`, tmpfs; `/certs` 0750 1000:1000, `caddy_data` 10002, extending D-105 (§8.9, §10.2); the development stack copies only `fcm-service-account.json` into `infra/dev/secrets/` (§10 intro); `/opt/kvitt/deployed-digests` with a WARNING on a changed digest and a workflow digest step; `kvitt_cicd` with `write:package` only (§10.3, §10.10); the plain root key (variant B) removed (§10.5.1); the `remote_ip` check (§10.10). D-114: pictures at most 16 000 000 pixels and 64 MB decoded, `image_invalid` (AC-4.4); `fcm_token` 1 to 512 characters, else `invalid_request` (§9.6, E-17). D-115: a token is accepted only after a 2xx, an unaccepted token counts against its IP, a 401 withdraws acceptance, entries pruned when their window expires; revises D-032 (§8.6). Without a decision id: the server overhaul (`8103dae` to `b985ce9`: shared row reads and inserts, invite creation and CLI helpers, one test harness `tests/common/`, clippy pedantic wins, synchronous `tasks::start`, `not_implemented` no longer produced; 268 server tests; all crates at their latest versions) (§6.3, §14.3.2, §14.7). Without a decision id: `install.sh` keeps the deployed `KVITT_TAG`, `--provenance=false`, the shared `tools/gradle-lock.sh`, `RCLONE_REMOTE` dropped from the development env (§10 intro, §10.3, §11.3); `strings.csv` 577 to 562 rows (§13.4); `localeFilters` en, sv (AC-19.1); `work-testing` and the screenshot harness fix (§6.2); release APK 10.0 MB (§10.6, §10.10); M12f row in §11.2 (941 tests); §11.4 validation reports (`docs/validation/ac-coverage.md`, `docs/validation/security-review.md`); the history rewrite and first push to `origin` (`18c527f`): every commit hash cited in this file is the rewritten one; `7c453d8` (the oxipng pass) no longer exists as a commit, its PNGs are in `18c527f`. | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index c9cdd94..ef7aa9e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,62 +3,75 @@ Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this. ## 1. Where things stand -- `main` at `4755fe0` (chips cleanup) plus the SPEC v0.17 docs commit from `agent-docs-v017` once it lands. History: `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `fad6fc9` M2b, `fdf2093` M2a, `040bf41` SPEC v0.4, `503cee5` M3, `c28c52b` M5, `982451d` SPEC v0.5, `bd6f10a` M4, `23a2f1e` SPEC v0.6, `ab298ab` M9, `e8ee518` SPEC v0.7, `6b60ef7` M12a, `7fb9f6f` SPEC v0.8, `e499107` M12c, `2e2ebe7` M12b, `d452fa6` UX Kalas categories and tokens, `16c4d01` UX sprint U2, `45eecd1` SPEC v0.9, `bdba9d3` M12d, `3080804` M6, `7bd0aa1` SPEC v0.10, `72f21b2` app debug fakes bindings (temporary, deleted by M8), `e6ad858` app release wiring of `:data` (pre-M8), `b9b061a` M7a, `6ac711d` M7e, `082b8dc` M7d, `31760f9` M7b, `1e42f76` M7c, `a54eb7c` M7f, `3a1bd14` M6b, `d504004` SPEC v0.11, `34a2590` M7g, `369e516` and `00a14a3` M8, `cf8ef45` SPEC v0.12, `1470a2f` M12e contract pass, `90505a8` M12e launcher icon, `03b7184` SPEC v0.13, `2b4e21c` Vico catalog entries removed (D-063), `c694f78` server FCM target in `fid` (D-096), `1e404cc` Pi image arm64 check (`docs/spikes/pi-image-arm64.md`), `fd60170` SPEC v0.14, `f0575d3` M8b UI fixes from the first phone test, `09f474f` `FcmMessageBody.fid` in `:data` (D-096 follow-up), `e7d963a` SPEC v0.15, `b481fab` M8c goldens at 1x in three variants (D-098), `64f5706` M8c S-22 licences row removed (D-099), `7c453d8` every PNG through oxipng, `57f984f` R8 release build, `3c68594` M8d editor rows (D-102), `3a3720b` SPEC v0.16, `5e027df` M2c-pi Pi bundle (D-103 to D-106), `15397e4` M8e S-19 option rows (D-107), `965bb5d` server Dockerfile `CARGO_BUILD_JOBS` argument, `ed1b130` M8f instant navigation (D-108), `4755fe0` unused editor chips removed. -- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2a (`fdf2093`), M2b (`fad6fc9`), M2c-dev (`c885aba`), M3 (`503cee5`), M4 (`bd6f10a`), M5 (`c28c52b`), M9 (`ab298ab`), M12a (`6b60ef7`), M12b (`2e2ebe7`), M12c (`e499107`), U1 and U2 (`16c4d01`, owner approved), M12d (`bdba9d3`), M6 (`3080804`), M7a to M7f, M6b (`3a1bd14`), SPEC v0.11 (`d504004`, D-069 to D-080), M7g feature cleanups (`34a2590`), M8 app integration (`369e516` + `00a14a3`, landed 2026-10-18, gate green: BUILD SUCCESSFUL, 710 actionable tasks), SPEC v0.12 (`cf8ef45`, D-081 to D-085), M12e contract pass (`1470a2f` + `90505a8`, landed 2026-10-18; gates green: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; cargo 253 tests, OpenAPI equality, vectors and migration byte-identity green), SPEC v0.13 (`03b7184`, D-086 to D-095), Vico removal (`2b4e21c`), FCM `fid` target (`c694f78`, D-096), Pi image arm64 check (`1e404cc`: C stage, CA roots and an OAuth plus FCM call from the arm64 image confirmed on the Mac; running on the Pi stays M2c-pi's), SPEC v0.14 (`fd60170`, D-096), M8 on-phone test (2026-10-18, passed), M8b (`f0575d3`, D-097), `FcmMessageBody.fid` (`09f474f`), SPEC v0.15 (`e7d963a`, D-097, D-100, D-101); landed today after v0.15: M8c (`b481fab`, `64f5706`, `7c453d8`; D-098, D-099), the R8 release build (`57f984f`), M8d (`3c68594`, D-102); SPEC v0.16 (`3a3720b`); then the M2c-pi bundle (`5e027df`, D-103 to D-106), M8e (`15397e4`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`965bb5d`), M8f (`ed1b130`, D-108) and the chips cleanup (`4755fe0`). -- M2c-pi bundle outcome (SPEC v0.17): `infra/pi/` with `compose.yaml`, `.env.example`, `Caddyfile`, `install.sh`, `deploy.sh [--skip-backup]` (backup before every deploy, `/opt/kvitt/previous-tag`), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` (D-103); images `gitea.lan/jacobh2/kvitt-server|kvitt-cert-sync|kvitt-backup`, Gitea workflow `.gitea/workflows/build-images.yaml` (runner `host`, secret `kvitt_cicd`, tags `v1.0.N` + `latest`, `CARGO_BUILD_JOBS=1`), `infra/build-images.sh` as Mac fallback, supercronic instead of crond (D-104); `backup` uid 10001, `rclone.conf` owned 10001 (D-105); cert-sync tar stream for both key variants, backup confirm-then-delete (D-106). Gate at landing: shellcheck clean on 18 scripts, compose config valid, cert-sync arm64 image builds; rehearsed on the Mac with Podman (cert-sync install, renew, skip, refuse; Caddy reload; `Authorization` redacted; TLS 1.1 refused; backup upload, `lsf` confirmation, delete after confirm, failed-upload retry; healthcheck red at 26 h; APK prune keeps 5). Not verifiable from the Mac: the add-on's `authorized_keys` options, the Gitea workflow run, pulls from `gitea.lan`, the router forward, Drive, real Docker compose. -- M8e, M8f and cleanup outcome (SPEC v0.17): S-19's account, payer and split are three fixed `EditorOptionRows` (D-107); navigation is instant, no `NavHost` transitions (D-108); `AccountChip`, `PayerChip`, `SplitChip` removed (`4755fe0`), S-9 keeps `DateChip` in `EditorChipRow`. -- M8 outcome (recorded in SPEC v0.12 as D-081 to D-085): both variants bind `:data` through `di/AppDataModule.kt`, debug fakes module gone (D-081, revises D-041); debug network security config allows cleartext everywhere, the AC-2.8 limit is enforced in `SessionRepositoryImpl.normaliseServerUrl`, pinned-root config behind `kvitt.tls.pinnedRoot` (D-082); FCM registration by Firebase Installation ID, firebase-messaging 26.0.0, no server change (D-083); invite links ignored while paired (D-084); generic-notification, deletion and `sync_batch` taps open Home, push in an expedited WorkManager job (D-085). -- M8 end-to-end test (2026-10-18, dev server, owner's S26): pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device` (the CLI has no partner-invite subcommand: small ops request for a later server plan); Home showed the account; the offline expense showed the pending marker and synced after reconnect (pending 0, the server has the row); the owner's device registered `fcm_token` and `app_version_name`; a push by `fid` was delivered and the notification tap worked. Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked, no complaint). -- M8b outcome (SPEC v0.15): invite-code field holds the raw code, hyphens as a `VisualTransformation` (cursor bug fixed, unit test); quick-row selected badge drawn above the ripple clip; `KvittTopAppBar` pads for the status-bar inset (`windowInsets` parameter), `KvittScaffold` follows; chart bars top 4, bottom 2 dp (D-097); 221 goldens re-recorded. `FcmMessageBody.fid` in `:data` followed (`09f474f`). -- M8c, R8 and M8d outcome (SPEC v0.16): goldens `_sv_light`, `_sv_dark`, `_en_light` plus `_sv_light_200`, at 1x (`w411dp-hdp-mdpi`), oxipng-compressed: 1143 PNGs / 131 MB to 865 / 26 MB, mock-ups 36.8 to 26.0 MB (D-098, revises D-066); S-22 has no licences row, `settings_about_licences` gone (D-099, OQ-5 closed); release APK R8-shrunk, about 11 MB (was 43 MB), `mapping.txt` to be kept beside the APK by the publish script (SPEC §10.6); release signing verified (alias `kvitt`, RSA 4096, v2); S-7 options as four fixed rows `EditorOptionRows`, connected buttons 8 dp side padding (D-102). -- Owner answers 2026-10-18: "boende" keeps suggesting `travel` (no change); the 128 px picture size is not needed; OQ-5 "no, hide the button" (implemented by M8c); the account page split bar stays as designed; M2c-pi becomes an owner-run bundle (D-100); the keystore lives at the repository root (D-101); the Gitea push waits for fewer goldens and a history rewrite, no LFS. -- M12e outcome (recorded in SPEC v0.13 as D-086 to D-095): E-3 `invite_kind` and `Pairing`, S-2 only after a `partner` pairing (D-086); admin name in E-12 links via `?from=`, passed on by E-4, S-1 help line names the admin (D-087); `SyncSummary.lastAttemptFailed` (D-088); `Rejection.actor` (D-089); `X-Kvitt-App-Version-Name`, migration 0003, server schema 3, S-20 "Kvitt 1.1" (D-090); `NewRecurringTemplate` (D-091); `UpdateCheck` (D-092, revises D-073); `SettleState.untouched` (D-093, revises D-074); D-027 re-stamp in `:data` (D-094); `PushEvent` in `:core:model`, six screen-hosted snackbar hosts, snackbar resource ids, adaptive vector launcher icon (D-095, revises D-071). 128 px picture size: the owner confirmed it is not needed (single 512 size, SPEC §14.2.4). -- Compatibility: the M12e APK cannot pair or list devices against a pre-M12e server (DTOs without defaults, by design), so the server is rebuilt and redeployed before the new APK is installed (SPEC §14.8). Old phones on the new server work. -- In flight: `agent-docs-v017` (this docs change: SPEC v0.17, D-103 to D-108), an AC-coverage audit (its report to be committed later as `docs/validation/ac-coverage.md`). Then a one-off history rewrite (drop the superseded PNG blobs; no clone exists) and the first push to `origin` (owner authorised, no confirmation needed), which also triggers the Gitea Actions workflow. -- Remote: `origin` on Gitea exists and the SSH key works; the golden reduction (M8c) has landed; the history rewrite and the first push follow this docs pass (owner authorised 2026-10-18, no further confirmation). The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone-drive-token.json`, turned into `infra/secrets/rclone.conf` with `[gdrive]` `type`, `scope`, `token`; the folder id stays `RCLONE_FOLDER_ID` in `.env`; both git-ignored); the cert-sync key `infra/secrets/ha_ssh_key` exists. +- `main` at `b985ce9` (the server overhaul, landed after M12f's `a0259ab`) plus the SPEC v0.18 docs commit from `agent-docs-v018` once it lands. `origin/main` (Gitea) at `18c527f`; everything after it is local until the next push. +- History rewrite and first push, 2026-10-18 (owner authorised): superseded PNG versions were dropped from the whole history (no LFS), the current goldens and UX mock-ups restored in `18c527f`; pack 45 MiB; `origin/main` pushed at `18c527f`. A bundle of the pre-rewrite history (`kvitt-pre-rewrite.bundle`) is kept outside the repository, in the orchestrator's scratchpad. Every commit from M2b on got a new hash; the oxipng pass (`7c453d8` before the rewrite) no longer exists as a commit (it only touched PNGs). SPEC, `docs/decisions.md`, this file and `prompts/README.md` cite the rewritten hashes since v0.18 (`7c453d8` stays as the oxipng pass's name; `docs/validation/ac-coverage.md` keeps its pre-rewrite hashes verbatim); plans, spike reports and READMEs may still cite pre-rewrite hashes: find a commit by its subject with `git log --oneline --grep`. +- History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f. +- Landed since SPEC v0.17 (recorded in v0.18): + - Infra overhaul (`2fac1ec`, `61f92c8`, `fc6126c`, `b647faf`, `e70fe0d`; no decision): `tools/gradle-lock.sh` shared by `gradle-serial.sh`, `clean-builds.sh` and `infra/build-images.sh`; the pre-commit hook runs through `gradle-serial.sh` with JDK 25 on `PATH` (hooks reinstalled in `main` after `e70fe0d`); `install.sh` keeps the deployed `KVITT_TAG`; `--provenance=false` on buildx pushes, the workflow token through env; `RCLONE_REMOTE` dropped from the dev env. + - Server gap pass (`8660b7f`, D-113): `kvitt-server invite-partner` (0 ok, 2 household full, 3 no admin), so a scripted partner needs no second admin device; `push sent` info line; CLI tests (`invite-partner`, `migrate`, `healthcheck`, SIGTERM drain); `tools/contracts-check.py` 134 checks. + - Infra security (`7fb86f5`, D-117): Caddy log redaction of `/i/` and query strings, CSP and three more response headers, Caddy non-root (uid 10002, read-only, caps dropped), `/certs` 0750, dev stack mounts only the FCM key, `/opt/kvitt/deployed-digests` with a WARNING on change, `kvitt_cicd` with `write:package` only, the plain root key (old variant B) not acceptable. + - M12f (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`; D-109 to D-112, D-116): S-16 lost version read-only, S-22 " (schema )" from `/health`, `SettlementRepository` returns null instead of throwing, E-17 retry after each successful sync, invite links dropped while paired, private notifications on the lock screen, `localeFilters` en/sv, `:data` tests (429, D-040, WorkManager via `work-testing`), the home/account screenshot harness fix (stale `sv_dark` goldens re-recorded), 19 unused string keys removed (`strings.csv` 562 rows). Gate at landing: 941 tests. Release APK 10.0 MB. +- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4); M-1 and L-3 wait for the owner (§4). H-1 must be fixed before port 8443 is opened. +- Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18. +- In flight: the Android overhaul agent only (and this docs pass); then a push to `origin`. +- Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2). +- Compatibility: an APK from M12e on cannot pair or list devices against a pre-M12e server, so the server is rebuilt and redeployed before a new APK is installed (SPEC §14.8). The dev server already runs schema 3. +- Remote: `origin` on Gitea, SSH key works. The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone.conf`, `[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`); the cert-sync key `infra/secrets/ha_ssh_key` exists (all git-ignored). The first push triggered the Gitea Actions workflow; its result is the owner's §3 item 1. - Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test` (from `server/`). Both green on `main` as far as reported. -- The UX sprint branch `UX-sprint` and its worktree `~/orca/workspaces/kvitt/UX-sprint` were removed on 2026-10-18 (fully merged). -- Importer: the dry run of 2026-10-18 with M12b's mapping reconciled on both real files (Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped). The CSV files stay in `export/` (git-ignored). -- Development stack (M2c-dev): running on the Mac, reachable at `http://192.168.1.210:8080`; rebuilt on 2026-10-18 with the M12e server and the `fid` target (a push by `fid` arrived in the M8 test), database at schema 3, data kept. Backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` (before schema 2) and `infra/dev/backups/kvitt-20261018T130443Z.sqlite.gz` (before schema 3) exist (git-ignored). On this Podman setup `infra/dev/up.sh` alone does not swap in a rebuilt image: run `infra/dev/up.sh --down`, then `infra/dev/up.sh`. The owner's phone paired with the bootstrap code on 2026-10-18; a scripted partner exists, joined through a second admin device paired with `kvitt-server pair-device` (tokens held by the orchestrator outside the repo). 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). -- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server for the M8 test on 2026-10-18; the next install carries M8b to M8f. +- Importer: the dry run of 2026-10-18 reconciled on both real files (Vardag 1746 rows, Hemma 37 rows, 0 unmapped). The CSV files stay in `export/` (git-ignored). +- Development stack (M2c-dev): running on the Mac at `http://192.168.1.210:8080`, database at schema 3, data kept; backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` and `kvitt-20261018T130443Z.sqlite.gz` (git-ignored). On this Podman setup run `infra/dev/up.sh --down`, then `infra/dev/up.sh`, to swap in a rebuilt image. Since `7fb86f5` `up.sh` copies only `fcm-service-account.json` into `infra/dev/secrets/` (git-ignored). The owner's phone is paired with it; a scripted partner exists (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`. +- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the next install carries M8b to M8f and M12f. +- Disk: the MacBook had about 400 MB free during this docs pass (2026-10-18); run `tools/clean-builds.sh` before the next Gradle build. ## 2. Open work (one line each, with who owns it: agent or owner) -- Land `agent-docs-v017` (orchestrator): SPEC v0.17, D-103 to D-108, HANDOFF, `prompts/README.md`, design-spec §2.11, §3.7 and S-19. -- AC-coverage audit (agent, running): report to `docs/validation/ac-coverage.md`, committed later. -- History rewrite (drop superseded PNG blobs; no clone exists), then the first push to Gitea `origin` (orchestrator; owner authorised, no confirmation needed); the push triggers the Gitea Actions workflow (§3 item 1). +- Land `agent-docs-v018` (orchestrator): SPEC v0.18, D-109 to D-117, `docs/validation/ac-coverage.md`, `docs/validation/security-review.md`, HANDOFF, `prompts/README.md`, design-spec S-16, S-22 and the S-1 permission line. +- L-10 (a member gets `forbidden` rather than `not_found` for another user's device, so the id's owner is visible): left as the spec says; owner decision pending (§4). +- Tooling request (no plan yet): `cargo-audit` in the server gate. +- Android overhaul (agent, running). +- `infra/README.md` lines (infra agent, no plan yet): drop "(milestone M2a)" on the `build.sh` server line; a `buildx rm` note for the builder config; the operations table still says "APKs about 20 MB each" (10 MB since R8, SPEC §10.10). +- Audit items still open: the AC-18.2 restore drill (`docs/validation/restore-drill.md`, M10); AC-18.1, AC-20.1 and AC-16.3 shown on the Pi and phones (M2c-pi, M10); a `KvittWorkerFactory` test (Android agent); an S-1 design state if the owner wants a confirm step for an unusual server (OQ-6, default not in v1). +- Unverified risk: the workflow pushes a git tag; whether that re-triggers the path-filtered workflow is not known (watch the first runs). +- Push the commits after `18c527f` to `origin` (orchestrator, when the owner asks; pushes need an explicit ask). - Install the next debug build and have the owner look (§3 item 2); build a signed release for the smoke taps (§3 item 3). -- Owner runs the M2c-pi bundle on the Kvitt Pi (§3 item 4); record the add-on key variant (A/B) in `infra/README.md` and the router-forward answer. -- Cleanup (agents of the owning modules, no plan yet): 20 unused strings R8 found (`editor_next`, `home_notification_rationale`, `settle_empty_settled`, `home_sync_status_tag_*`, …) could be deleted by their modules; optional `localeFilters` en+sv. -- Ops request (no plan yet): a `kvitt-server` subcommand for a partner invite, so a scripted partner needs no second admin device. -- Plans (SPEC §11.2 v0.17), in dependency order; prompt `prompts/.md`: - - M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12e, U1, U2, R8 release build, M2c-pi bundle: landed. - - Next: the owner runs the M2c-pi bundle on the Kvitt Pi (D-100); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045). +- Owner runs the M2c-pi bundle on the Kvitt Pi (§3 item 5); record the accepted cert-sync key form and the router-forward answer in `infra/README.md`. +- Plans (SPEC §11.2 v0.18), in dependency order; prompt `prompts/.md`: + - M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12f, U1, U2, R8 release build, M2c-pi bundle, server gap pass, infra overhaul, infra security and the server overhaul: landed. + - Next: the Android overhaul; a server image with the H-1 fix before the router forward; the owner runs the M2c-pi bundle on the Kvitt Pi (D-100); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045). ## 3. HUMAN steps pending (relay in chat) -1. After the first push: tell me whether the Gitea Actions run ("Build Kvitt images") succeeded and how long it took. -2. Next install on the phone (yes/no each): screen changes are instant, with no fade or slide; plus the earlier checks: the title bar sits below the status bar; the quick-row selected badge is whole; the invite-code field cursor stays after the last character; the expense editor shows Konto, Datum, Betalt av, Delning as four rows that do not move when a value changes; the recurring editor shows Konto, Betalt av, Delning as three such rows; "Household" fits its half of the Analytics filter bar. -3. Release build smoke taps: the app opens; S-1 or Home renders; Settings, About shows the version; add one expense and watch it sync. -4. Pi, after the app is proven: the nine steps of `infra/README.md` "Pi: what the owner does": +1. Gitea, the repository, Actions: did the run "Build Kvitt images" triggered by the first push succeed (yes/no), and how long did it take? +2. Next install on the phone (yes/no each): screen changes are instant, with no fade or slide; Settings, Om Kvitt shows the server version as "… (schema 3)"; if Inställningar, Synkstatus lists a change under "Ändringar som inte synkades" (likely none), "Öppna" shows "Din version" with "Visa nuvarande version"; plus the earlier checks: the title bar sits below the status bar; the quick-row selected badge is whole; the invite-code field cursor stays after the last character; the expense editor shows Konto, Datum, Betalt av, Delning as four rows that do not move when a value changes; the recurring editor shows Konto, Betalt av, Delning as three such rows; "Household" fits its half of the Analytics filter bar. +3. Release build smoke taps: the app opens; S-1 or Home renders; Settings, Om Kvitt shows the version; add one expense and watch it sync. +4. Owner decisions from the security review (answer each): + 1. M-1: encrypt the Drive backups through an rclone `crypt` remote and re-authorise rclone with the narrower `drive.file` scope (a restore then needs the crypt passwords)? yes/no. + 2. M-2: which cert-sync key form did the Home Assistant add-on accept at Pi step 3: the restricted root key (variant A) or a dedicated non-root user? (A plain root key is not acceptable.) + 3. L-3: a separate per-IP limit for APK downloads (`/apk/files/*`): which number per minute (assumed 10 per minute per IP)? + 4. Gitea token: if the workflow's tag push fails with `kvitt_cicd` limited to `write:package`, may `write:repository` be added? yes/no. + 5. Confirm the numbers the server overhaul assumed and built: pictures capped at 16 megapixels; `fcm_token` at most 512 characters. yes/no. +5. Pi, after the app is proven: the nine steps of `infra/README.md` "Pi: what the owner does": 1. Provision: Raspberry Pi Imager, Raspberry Pi OS Lite (64-bit), user, SSH key, time zone; static DHCP lease in the router. 2. Docker Engine from Docker's Debian arm64 repository; `docker run --rm hello-world` prints "Hello from Docker!". - 3. Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, `authorized_keys`: add the cert-sync key as variant A (restricted `tar` command); if the add-on refuses it, variant B (plain key). Question: which variant was accepted, A or B? + 3. Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, `authorized_keys`: add the restricted cert-sync key line (variant A); if the add-on refuses it, a dedicated non-root user that can only read the certificate directory; never a plain root key. Question: which form was accepted? 4. On the Mac: `infra/pi/.env` from `.env.example`, every `CHANGE_ME` replaced; secrets staged into `infra/pi/secrets/`. 5. `scp` the bundle to the Pi, remove `infra/pi/secrets` on the Mac; on the Pi `ssh-keyscan` the Home Assistant host into `secrets/ha_known_hosts` (at least 1 line). 6. `./install.sh` on the Pi; expected end "Kvitt is running."; then remove `~/kvitt-pi/secrets`. - 7. Router, WAN, Virtual Server / Port Forwarding: TCP 8443 to the Kvitt Pi's 8443; on the phone with Wi-Fi off, `https://icehack.asuscomm.com:8443/health` shows `"status":"ok"` without a certificate warning. Question: yes/no? + 7. Router, WAN, Virtual Server / Port Forwarding: TCP 8443 to the Kvitt Pi's 8443 (only after the H-1 fix is deployed); on the phone with Wi-Fi off, `https://icehack.asuscomm.com:8443/health` shows `"status":"ok"` without a certificate warning (yes/no); then the `remote_ip` check of the README's operations table shows a public address (yes/no). 8. `/opt/kvitt/backup-now.sh` ends with "last-success: "; `/opt/kvitt/bootstrap.sh` prints the admin invite code (first install only). - 9. New versions: `/opt/kvitt/deploy.sh v1.0.N` after the Gitea workflow pushed that tag. + 9. New versions: `/opt/kvitt/deploy.sh v1.0.N` after the Gitea workflow pushed that tag; it prints the image digests and warns if a recorded tag changed digest. ## 4. Owner questions (asked, unanswered) -- None. OQ-3 is answered by placeholders (D-100); OQ-5 answered and implemented (D-099). +- The §3 item 1 workflow result and duration. +- The five security-review decisions of §3 item 4 (M-1 backup encryption, M-2 key form, L-3 APK download rate, `write:repository`, the 16 MP and 512-character assumptions). +- OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1". +- Security review L-10: should a member get `not_found` instead of `forbidden` for a device id of the other user (hides whose it is)? As built and as the spec says: `forbidden`. ## 5. Paused (waiting on something outside the repo) - Nothing. ## 6. Numbering -- Decisions: D-001 to D-108 used except D-008 and D-009. Next free: **D-109**. -- Spec version: v0.17 written; next **v0.18**. -- Server schema version **3** (migration 0003, landed with M12e); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.17. +- Decisions: D-001 to D-117 used, except D-008 and D-009 (never used); none reserved. Next free: **D-118**. +- Spec version: v0.18 written; next **v0.19**. +- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.18. - Sync protocol **1**, push payload **1**. ## 7. How the work is run diff --git a/docs/decisions.md b/docs/decisions.md index da05a3c..27fa0cb 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -9,12 +9,12 @@ 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-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-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-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 (`06f48ef`): 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-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 (`c277ee3`): 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 | @@ -30,66 +30,66 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-023 | 2026-10-17 | M2c-pi: ~~key-based SSH to the Kvitt Pi and the Gitea credentials are HUMAN steps; the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint;~~ (revised by D-100: no SSH from the Mac to the Pis and no agent allowances; the owner runs the `infra/pi/` bundle; the Gitea credentials stay a HUMAN step); the Gitea Actions workflow lives at `.gitea/workflows/`, owned by M2c-pi; secret files on the Pi (`ha_ssh_key`, `ha_known_hosts`, ~~`rclone.conf`~~) mode 0600 owned by uid 1000, the uid of the `cert-sync` ~~and `backup`~~ container (revised by D-105: `backup` runs as uid 10001 and `rclone.conf` is owned by 10001:10001); adding `gitea.lan` to `insecure-registries` is its own HUMAN step. | PLAN-M2c-pi open points (a) to (c). | SPEC §10.2, §10.3, §11.2, §11.3, §14.7; PLAN-M2c-pi | | D-024 | 2026-10-17 | Release keystore: `keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000`, created by the owner ~~and kept outside the repository~~ (was `android/keystore/release.jks`; revised by D-101: at the repository root, git-ignored by `*.jks`); `local.properties` holds its absolute path. | §10.6 did not fix the key parameters (PLAN-M10 H-1); a keystore outside every checkout cannot be committed or deleted with a worktree. | SPEC §10.6, §11.2, §11.3; PLAN-M10 | | D-025 | 2026-10-17 | Production Splitwise import (M11): a backup first; user ids resolved by display name from the database; CSVs copied to the Pi into `/opt/kvitt/import/`, bind-mounted read-only into `server` as `/import`; the import runs as `docker compose run --rm --entrypoint kvitt-import server ...` with the server stopped (`--entrypoint` because the image's entrypoint is `kvitt-server`); CSVs deleted afterwards. The category table stays at `server/crates/kvitt-import/data/splitwise_categories.csv`. | PLAN-M11 open points (a) to (c); PLAN-M9 table location. | SPEC §10.2, §10.8, §11.2, §11.3; PLAN-M2c-pi, PLAN-M9, PLAN-M11 | -| D-026 | 2026-10-17 | A 409 `cursor_ahead` on pull is treated as epoch recovery: cursor 0, epoch cleared, every local expense and settlement marked pending with a new `pending_mutation_id`, cycle restarted. | M5 report (`c28c52b`): a cursor ahead of the server means the server lost rows, as after a restore without `rotate-epoch`. | SPEC §14.4.2 | -| D-027 | 2026-10-17 | A `clock_skew` rejection gives the row a new `pending_mutation_id` and the row stays pending (the server stored the rejection under the old id and would replay it). Orchestrator addition: on the first sync attempt after a `clock_skew` rejection, the phone re-stamps `updated_at` of each such pending row to its current clock before pushing, so a corrected clock heals the row. ~~The re-stamp is not in `:data` at `c28c52b`: contract request for M8 or a small `:data` follow-up (HANDOFF open work).~~ (Revised by D-094: implemented by M12e as a re-stamp of pending rows stamped later than now, at the start of a cycle with `clock_skew` set.) | M5 report; without the re-stamp a row stamped by a wrong clock would stay rejected after the clock is fixed. | SPEC §14.4.5 | +| D-026 | 2026-10-17 | A 409 `cursor_ahead` on pull is treated as epoch recovery: cursor 0, epoch cleared, every local expense and settlement marked pending with a new `pending_mutation_id`, cycle restarted. | M5 report (`8f0e156`): a cursor ahead of the server means the server lost rows, as after a restore without `rotate-epoch`. | SPEC §14.4.2 | +| D-027 | 2026-10-17 | A `clock_skew` rejection gives the row a new `pending_mutation_id` and the row stays pending (the server stored the rejection under the old id and would replay it). Orchestrator addition: on the first sync attempt after a `clock_skew` rejection, the phone re-stamps `updated_at` of each such pending row to its current clock before pushing, so a corrected clock heals the row. ~~The re-stamp is not in `:data` at `8f0e156`: contract request for M8 or a small `:data` follow-up (HANDOFF open work).~~ (Revised by D-094: implemented by M12e as a re-stamp of pending rows stamped later than now, at the start of a cycle with `clock_skew` set.) | M5 report; without the re-stamp a row stamped by a wrong clock would stay rejected after the clock is fixed. | SPEC §14.4.5 | | D-028 | 2026-10-17 | Periodic sync work is named `kvitt-sync-periodic` (policy `KEEP`); one-off work keeps `kvitt-sync` (`APPEND_OR_REPLACE`). | M5 report: WorkManager cannot share one unique name between periodic and one-off work. | SPEC §14.4.1, AC-15.3 | | D-029 | 2026-10-17 | A REST call that fails on the network returns `OnlineResult.Failed("network", null)`; `network` is the one allowed code outside §14.3.2. | M5 report: the UI must tell "Could not reach the server" (§9.9) from `Offline` (call not attempted). | SPEC §14.2.4 | -| D-030 | 2026-10-17 | Picture version after a removal: E-10 deletes the JPEGs and leaves an empty `-removed` marker in `pictures//`; the next upload uses the highest version on disk plus one, so caches keyed by (`user_id`, `picture_version`) never show a removed picture; the first upload is version 1. | M3 report (`503cee5`): `picture_version` is null after a removal, so the next version needs a record. | SPEC AC-4.4, §10.4, E-10 | +| D-030 | 2026-10-17 | Picture version after a removal: E-10 deletes the JPEGs and leaves an empty `-removed` marker in `pictures//`; the next upload uses the highest version on disk plus one, so caches keyed by (`user_id`, `picture_version`) never show a removed picture; the first upload is version 1. | M3 report (`7869ec4`): `picture_version` is null after a removal, so the next version needs a record. | SPEC AC-4.4, §10.4, E-10 | | D-031 | 2026-10-17 | Failed invite accept (AC-2.12): any 4xx answer other than 429 counts; after 10 failures further accepts from that IP are refused with `rate_limited` until 60 minutes after the first counted failure; windows are fixed from the first counted request. | M3 report: AC-2.12 did not define "failed" or the window. | SPEC AC-2.12, §8.6 | | D-032 | 2026-10-17 | Rate-limit keying (§8.6): per device by the hash of the bearer token, no database lookup; a 401 also counts against the client IP; tokens never accepted are refused once the IP is over its limit; `KVITT_TRUSTED_PROXY` re-resolved at most every 60 s; the client IP is the last `X-Forwarded-For` entry from the trusted proxy; `serve` must use `into_make_service_with_connect_info` (being added by M4). | M3 report: keying and proxy handling were unspecified; the limiter must not cost a database query. | SPEC §8.6, §10.4, §11.3 | | D-033 | 2026-10-17 | Resume rule (§9.5): `last_generated_period` moves forward only when the latest past period is at or after `start_period`, so resuming a not-yet-started template keeps its start. | M3 report: the v0.4 rule would skip a future start on resume. | SPEC §9.5 | | D-034 | 2026-10-17 | Storage normalisation: strings stored trimmed, `notes` included; PNG and WebP transparency flattened onto white; `fcm_token` non-empty or null (`""` is `invalid_request`); revoking a device clears its `fcm_token`; archive or unarchive in the state already held returns the row unchanged with no new `server_version`; a new category's `sort_order` is the maximum over non-deleted categories plus 10. | M3 report: details the spec left open. | SPEC §8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26 | -| D-035 | 2026-10-18 | A mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable (not a canonical lowercase hyphenated UUID, or an `entity_type` other than `expense` or `settlement`) fails the whole `POST /sync` with 400 `invalid_request`, checked before the transaction; every other shape error is a per-mutation `rejected` / `validation_failed` (§14.4.4 step 3). | M4 report (`bd6f10a`): without those keys no per-mutation result (keyed by `mutation_id`, `entity_type`, `entity_id`) can be formed or stored in `sync_mutations`. | SPEC §14.4.4 | -| D-036 | 2026-10-18 | `POST /sync` with 0 mutations is 422 `batch_too_large`; the §14.3.2 meaning reads "Number of mutations in `POST /sync` outside 1 to 100". | M4 report (`bd6f10a`): §9.6 said 1 to 100 but §14.3.2 named only "more than 100". | SPEC §9.6, §14.3.2, §14.4.4 | -| D-037 | 2026-10-18 | A delete of a never-synced row (§14.4.4 step 7, no stored row) runs the full step-8 validation (field rules, domain rules, references), so the stored tombstone satisfies the schema. Every rejection carries the stored row or `null` as `current`, at steps 3 and 4 too. Step 11 keeps the stored `created_at` (a pushed one is ignored on update). Pushed ids must be canonical lowercase hyphenated UUIDs; another spelling of a valid UUID is not accepted. | M4 report (`bd6f10a`): v0.5 validated only references for such a tombstone, left `current` open at steps 3 and 4, and did not say whether `created_at` may change. | SPEC §14.4.3, §14.4.4 | -| D-038 | 2026-10-18 | FCM retry is one send plus up to three retries after 1 s, 5 s, 25 s (four sends at most) on 429, 5xx and network errors. On 401 the cached access token is dropped and the message is not retried; other 4xx are not retried. Retries run inside the single sender task, blocking it up to 31 s per device; accepted for two users. | M4 report (`bd6f10a`): "3 attempts with delays 1 s, 5 s, 25 s" was ambiguous (three delays need four sends) and 4xx handling was open. | SPEC §14.6 | -| D-039 | 2026-10-18 | `sync_mutations` pruning deletes rows whose `received_at` is strictly older than 90 days (a row exactly 90 days old is kept); PLAN-M4's "90 days and older" corrected. Seam change to D-015: `tasks::start` returns the `AppState` with the real push queue installed and `serve` builds the router from it; `AppState::new` defaults to `SqlAuditWriter`; `NoopAuditWriter` is removed; `NoopPushQueue` stays for push disabled. | M4 report (`bd6f10a`): "older than 90 days" (D-021) and PLAN-M4 disagreed at the boundary; the push queue must exist before the router is built. | SPEC §6.3, §14.1, §14.4.9, §14.6; PLAN-M4; server/README.md | +| D-035 | 2026-10-18 | A mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable (not a canonical lowercase hyphenated UUID, or an `entity_type` other than `expense` or `settlement`) fails the whole `POST /sync` with 400 `invalid_request`, checked before the transaction; every other shape error is a per-mutation `rejected` / `validation_failed` (§14.4.4 step 3). | M4 report (`15056dc`): without those keys no per-mutation result (keyed by `mutation_id`, `entity_type`, `entity_id`) can be formed or stored in `sync_mutations`. | SPEC §14.4.4 | +| D-036 | 2026-10-18 | `POST /sync` with 0 mutations is 422 `batch_too_large`; the §14.3.2 meaning reads "Number of mutations in `POST /sync` outside 1 to 100". | M4 report (`15056dc`): §9.6 said 1 to 100 but §14.3.2 named only "more than 100". | SPEC §9.6, §14.3.2, §14.4.4 | +| D-037 | 2026-10-18 | A delete of a never-synced row (§14.4.4 step 7, no stored row) runs the full step-8 validation (field rules, domain rules, references), so the stored tombstone satisfies the schema. Every rejection carries the stored row or `null` as `current`, at steps 3 and 4 too. Step 11 keeps the stored `created_at` (a pushed one is ignored on update). Pushed ids must be canonical lowercase hyphenated UUIDs; another spelling of a valid UUID is not accepted. | M4 report (`15056dc`): v0.5 validated only references for such a tombstone, left `current` open at steps 3 and 4, and did not say whether `created_at` may change. | SPEC §14.4.3, §14.4.4 | +| D-038 | 2026-10-18 | FCM retry is one send plus up to three retries after 1 s, 5 s, 25 s (four sends at most) on 429, 5xx and network errors. On 401 the cached access token is dropped and the message is not retried; other 4xx are not retried. Retries run inside the single sender task, blocking it up to 31 s per device; accepted for two users. | M4 report (`15056dc`): "3 attempts with delays 1 s, 5 s, 25 s" was ambiguous (three delays need four sends) and 4xx handling was open. | SPEC §14.6 | +| D-039 | 2026-10-18 | `sync_mutations` pruning deletes rows whose `received_at` is strictly older than 90 days (a row exactly 90 days old is kept); PLAN-M4's "90 days and older" corrected. Seam change to D-015: `tasks::start` returns the `AppState` with the real push queue installed and `serve` builds the router from it; `AppState::new` defaults to `SqlAuditWriter`; `NoopAuditWriter` is removed; `NoopPushQueue` stays for push disabled. | M4 report (`15056dc`): "older than 90 days" (D-021) and PLAN-M4 disagreed at the boundary; the push queue must exist before the router is built. | SPEC §6.3, §14.1, §14.4.9, §14.6; PLAN-M4; server/README.md | | D-040 | 2026-10-17 | A push refused as a whole (400 `invalid_request`, 413 `payload_too_large`, 409 `household_incomplete`, 422 `batch_too_large`) counts as a batch with no progress: the cycle goes on to pull and records `last_error_code`; 5xx and 429 retry with backoff; 401 and 426 stop. (Proposed by M5 as D-030, renumbered.) | M5 report: §14.4.5 covered only network errors, 5xx and 401. | SPEC §14.4.5 | | D-041 | 2026-10-17 | `:data`'s `DataModule` provides implementations only; the interface bindings live in `DataBindingsModule` (`@DisableInstallInCheck`), which `:app` includes ~~for release so the debug fakes do not duplicate bindings~~ (revised by D-081: `:app` includes it in both variants; the debug fakes module is gone). (M5's D-031, renumbered.) | M5 report: Hilt fails on duplicate bindings when debug binds fakes. | SPEC §6.2, §11.3 (M8); PLAN-M8 | | D-042 | 2026-10-17 | Room tests run on Robolectric with the framework SQLite driver; migration tests build from the exported schema JSON rather than `room3-testing`. (M5's D-032, renumbered.) | M5 report: no device needed for the gate. | SPEC §6.2 | | D-043 | 2026-10-17 | Two extra DataStore keys: the pairing status in `auth.preferences_pb`, and the installed version code that received a 426 in `prefs.preferences_pb` (syncing stops until the installed version changes). The token is encrypted with Keystore key `kvitt_token_key`, AES-256-GCM (as §8.2). (M5's D-033, renumbered.) | M5 report. | SPEC §8.2, §14.2.2, §14.4.5 | | D-044 | 2026-10-18 | Nine Swedish Splitwise category names, found unmapped in the importer rehearsal on the real Swedish export, are added to the §10.8 table and to `server/crates/kvitt-import/data/splitwise_categories.csv` with the key of their English equivalent: Städning → `utilities` (Cleaning); Verktyg - Övrigt → `utilities` (Utilities - Other); Tjänster → `home` (Services); Cykel → `transport` (Bicycle); Flyg → `travel` (Plane); Spel → `entertainment` (Games); Kläder → `shopping` (Clothing); Livet - Övrigt → `other` (Life - Other); Utbildning → `other` (Education). The test `category_table_matches_the_spec` keeps the two in step. | The rehearsal of 2026-10-18 left 21 rows of the Vardag file as `other`, counted unmapped, only because these names were missing from D-003's table. | SPEC §10.8; `server/crates/kvitt-import/data/splitwise_categories.csv` | -| D-045 | 2026-10-18 | Import reconciliation. Inside the import transaction, after the rows are written, each user's balance of the whole account (`net(u)`, §9.3) must equal the sum of that member's values in the file's totals rows (`Total balance` / `Totalsumma`), one totals row allowed per currency, a non-SEK one converted with its `--fx` rate. A mismatch or a missing totals row rolls the run back and exits 4; a dry run reports the same. The comparison covers the whole account, so importing into an account that already holds other rows rolls back. When any `--fx` conversion took place, a mismatch is reported in the summary but accepted (per-value rounding can make an exact match impossible). | M9 report (`ab298ab`): v0.6 ignored the totals row, and M11's gate ("imported balances equal Splitwise's totals") is cheapest checked by the importer itself, before anything is committed. | SPEC §10.8, AC-17.5, §11.2 (M11) | -| D-046 | 2026-10-18 | Importer CLI semantics. `--user =` takes a user id or an exact display name that exactly one user has; the two arguments must name the two household users. The importer never creates or migrates a database and requires `meta.schema_version` = `1` (revised by D-054: the schema version the server's migrations produce, now `2`). A dry run runs the full transaction and rolls it back. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import (header, invalid rows, non-SEK without `--fx`), 4 reconciliation mismatch. The import key uses an existing account's stored name, so a re-run with another spelling finds its rows. An empty description or one over 1000 code points makes the row invalid; a description over 100 code points is cut with the full text in `notes`; settlement notes are cut to 200 code points; both nets zero means the owner pays in mode `full`. Idempotency uses the existing `import_key` columns; no schema change. | M9 report (`ab298ab`): §10.8 left the user lookup, database handling, dry-run mechanics, exit codes and these edge cases open. | SPEC §10.8, AC-17.5; server/README.md | +| D-045 | 2026-10-18 | Import reconciliation. Inside the import transaction, after the rows are written, each user's balance of the whole account (`net(u)`, §9.3) must equal the sum of that member's values in the file's totals rows (`Total balance` / `Totalsumma`), one totals row allowed per currency, a non-SEK one converted with its `--fx` rate. A mismatch or a missing totals row rolls the run back and exits 4; a dry run reports the same. The comparison covers the whole account, so importing into an account that already holds other rows rolls back. When any `--fx` conversion took place, a mismatch is reported in the summary but accepted (per-value rounding can make an exact match impossible). | M9 report (`50339ae`): v0.6 ignored the totals row, and M11's gate ("imported balances equal Splitwise's totals") is cheapest checked by the importer itself, before anything is committed. | SPEC §10.8, AC-17.5, §11.2 (M11) | +| D-046 | 2026-10-18 | Importer CLI semantics. `--user =` takes a user id or an exact display name that exactly one user has; the two arguments must name the two household users. The importer never creates or migrates a database and requires `meta.schema_version` = `1` (revised by D-054: the schema version the server's migrations produce, now `2`). A dry run runs the full transaction and rolls it back. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import (header, invalid rows, non-SEK without `--fx`), 4 reconciliation mismatch. The import key uses an existing account's stored name, so a re-run with another spelling finds its rows. An empty description or one over 1000 code points makes the row invalid; a description over 100 code points is cut with the full text in `notes`; settlement notes are cut to 200 code points; both nets zero means the owner pays in mode `full`. Idempotency uses the existing `import_key` columns; no schema change. | M9 report (`50339ae`): §10.8 left the user lookup, database handling, dry-run mechanics, exit codes and these edge cases open. | SPEC §10.8, AC-17.5; server/README.md | | D-047 | 2026-10-18 | Visual direction: U1's direction 3 "Kalas" (cards, very round shapes, a solid category-colour tile on every expense, the seesaw balance) with the muted palette of `docs/ux/directions/3-kalas/` (owner approved 2026-10-18). Typeface per D-020 (Material 3 default); Kalas's preview font Nunito is dropped. U2's `design-spec.md` carries the tokens. | Owner's pick (UX-1); muted because bright colours tire over years of daily use. | SPEC §9.7, §13.4 | | D-048 | 2026-10-18 | Navigation stays at three destinations (Översikt, Statistik, Inställningar); §13.2 is binding. An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth tab and not taken for v1. | Owner's answer (UX-2). | SPEC §13.2 | -| D-049 | 2026-10-18 | Category suggestion (§9.11 CS-1 to CS-7): for a new expense, while the category was not picked by hand in this editor session, the editor selects (1) the category used most often among expenses with the same normalised description, (2) else most often among expenses with the same first token, ties by the most recent expense in list order, then category id, (3) else the longest matching entry of the fixed keyword table `contracts/category-keywords.csv`, (4) else the AC-8.6 default. Recomputed on every description and account change; no marker; editing never changes a category. `ExpenseRepository.suggestCategory(accountId, description)` in `:data`; pure `suggestCategory(description, history, keywords, activeCategories, fallback, unicode: UnicodeText): CategoryId?` in `:domain` (Kotlin only) with `suggest_category.json`. `UnicodeText` (`nfc`, `isLetter(codePoint)`, `isDigit(codePoint)` meaning Nd) is an interface in `:domain` that `:data` binds with `java.text.Normalizer` and `Character`, because pure modules cannot use `java.*`; the records `SuggestionHistoryEntry(description, categoryId, expenseDate, createdAt)`, `CategoryKeyword(keyword, match, defaultKey: String)`, `ActiveCategory(id, defaultKey: String?)` and the enum `KeywordMatch` live in `:domain`, with default keys as strings. History ties: count, then the latest (`expense_date`, `created_at`), then the lower category id; code points walked by surrogate pairs; keywords rank by code-point length, then table order. As landed by M12a (`6b60ef7`). | Owner (UX-3): most expenses repeat the same shops; fewer taps in the ten-second flow (§13.1). Replay of the household's 1 775 Splitwise expenses: 79.7 % right versus 30.5 % for the AC-8.6 default alone; "most often" beats "most recent" by about one point (`docs/ux/category-suggestion.md`). | SPEC AC-8.6, §9.11, §13.3 (S-7), §14.2.4, §14.5.5, §14.5.6, §14.7, §14.8 | +| D-049 | 2026-10-18 | Category suggestion (§9.11 CS-1 to CS-7): for a new expense, while the category was not picked by hand in this editor session, the editor selects (1) the category used most often among expenses with the same normalised description, (2) else most often among expenses with the same first token, ties by the most recent expense in list order, then category id, (3) else the longest matching entry of the fixed keyword table `contracts/category-keywords.csv`, (4) else the AC-8.6 default. Recomputed on every description and account change; no marker; editing never changes a category. `ExpenseRepository.suggestCategory(accountId, description)` in `:data`; pure `suggestCategory(description, history, keywords, activeCategories, fallback, unicode: UnicodeText): CategoryId?` in `:domain` (Kotlin only) with `suggest_category.json`. `UnicodeText` (`nfc`, `isLetter(codePoint)`, `isDigit(codePoint)` meaning Nd) is an interface in `:domain` that `:data` binds with `java.text.Normalizer` and `Character`, because pure modules cannot use `java.*`; the records `SuggestionHistoryEntry(description, categoryId, expenseDate, createdAt)`, `CategoryKeyword(keyword, match, defaultKey: String)`, `ActiveCategory(id, defaultKey: String?)` and the enum `KeywordMatch` live in `:domain`, with default keys as strings. History ties: count, then the latest (`expense_date`, `created_at`), then the lower category id; code points walked by surrogate pairs; keywords rank by code-point length, then table order. As landed by M12a (`aa765bb`). | Owner (UX-3): most expenses repeat the same shops; fewer taps in the ten-second flow (§13.1). Replay of the household's 1 775 Splitwise expenses: 79.7 % right versus 30.5 % for the AC-8.6 default alone; "most often" beats "most recent" by about one point (`docs/ux/category-suggestion.md`). | SPEC AC-8.6, §9.11, §13.3 (S-7), §14.2.4, §14.5.5, §14.5.6, §14.7, §14.8 | | D-050 | 2026-10-18 | S-1 has a required "Ditt namn" display-name field (1 to 40 code points, §9.6) on a first pairing, hidden in the "Para ihop telefonen igen" variant; a partner confirms it again on S-2 (AC-2.10). | UX-4: AC-2.4 sends a display name with the accept call, but S-1 listed no field for it. Owner OK. | SPEC AC-2.4, §13.3 (S-1) | -| D-051 | 2026-10-18 | The §9.7 colour hex values are replaced by the muted Kalas values when U2 reports; the token names are fixed now, the two new ones being `slate` (insurance) and `amber` (fees), whose values are `TODO(U2)`. M6 starts only after the values have landed in §9.7. Fulfilled in SPEC v0.9 (2026-10-18): the §9.7 values are those of `docs/ux/design-spec.md` §2.3 (U2, landed `16c4d01`), `slate` `#5E2D4E` / `#B0719A`, `amber` `#937D63` / `#DFCFC0`, light `brown` `#6B5244`. | UX-5: several provisional values (light orange, yellow, lime) fail contrast as icon colours on tinted containers; UX-8 needs two more tokens. | SPEC §9.7, §13.4 | +| D-051 | 2026-10-18 | The §9.7 colour hex values are replaced by the muted Kalas values when U2 reports; the token names are fixed now, the two new ones being `slate` (insurance) and `amber` (fees), whose values are `TODO(U2)`. M6 starts only after the values have landed in §9.7. Fulfilled in SPEC v0.9 (2026-10-18): the §9.7 values are those of `docs/ux/design-spec.md` §2.3 (U2, landed `6da6b1b`), `slate` `#5E2D4E` / `#B0719A`, `amber` `#937D63` / `#DFCFC0`, light `brown` `#6B5244`. | UX-5: several provisional values (light orange, yellow, lime) fail contrast as icon colours on tinted containers; UX-8 needs two more tokens. | SPEC §9.7, §13.4 | | D-052 | 2026-10-18 | Smaller UX rules: saving an expense shows "Utgiften sparades" without "Ångra" (S-7); an archived account's detail hides the add-expense FAB and keeps settling (S-5); the per-account settle-up keeps the direction swap and an editable amount, its one account row fixed (S-9); avatar initials use two colour tokens `avatar-1`, `avatar-2`, the slot a stable function of the user id (AC-4.5). The owner approved the Swedish wording "Ni är kvitt", "Gör upp", "Du lånade ut / Du lånade", "Inte inblandad" (AC-8.10; the copy lives in `docs/ux/strings.csv`). U2 renames the `strings.csv` keys to the feature prefixes of D-017. | UX-6, found during U1: an undo would be an audited create and delete that also notifies the partner; archived accounts take no new expenses (AC-6.4); §9.4 allows per-account settle-ups either way and AC-11.4 says the amount "defaults to". | SPEC AC-4.5, AC-8.10, §13.3 (S-5, S-7, S-9) | | D-053 | 2026-10-18 | Splitwise categories are mapped as finely as the fourteen categories allow: Rent, Mortgage, Hyra, Avbetalning/Amortering → `housing`; Insurance, Försäkringar → `insurance`; Childcare, Barnomsorg → `children`; Taxes, Skatter → `fees`; the Swedish labels El, Värme/gas, Vatten, Sophämtning (`utilities`) and Husdjur (`home`) added by analogy, to be checked against Splitwise (an unseen name only stays unmapped). The §10.8 table and `server/crates/kvitt-import/data/splitwise_categories.csv` change together (M12b). | UX-7 (owner, 2026-10-18): §10.8 collapsed Försäkringar (26 rows), Barnomsorg (73) and Skatter (19) into `other` and Hyra (106) and Avbetalning/Amortering (48) into `home`; "Hemförsäkring" must not end up in Övrigt. | SPEC §10.8 | | D-054 | 2026-10-18 | Fourteen default categories: `housing` Boende (`key`, `indigo`, 25), `insurance` Försäkringar (`shield`, `slate`, 45), `children` Barn (`child_care`, `lime`, 85), `fees` Avgifter & skatter (`account_balance`, `amber`, 95) join the ten; `home` keeps furniture, supplies, maintenance, electronics and services. Ids `01920000-0000-7000-8000-000000000011` to `…014`, `created_at` `2026-10-18T00:00:00.000Z`, `server_version` from the global sequence. Server schema 2 through migration 0002, which rebuilds `categories` (the v1 `CHECK` lists ten keys) under `PRAGMA defer_foreign_keys`, inserts the four rows and sets `schema_version` 2; its text is locked as `contracts/schema/migrations/0002_categories_v2.sql` (§14.1.1); `server-v1.sql` stays byte-locked. Icons 33 (`key`, `shield`, `account_balance`), colours 14, `DefaultCategoryKey` and `ColorToken` extended, OpenAPI enum, E-1 `schema_version` 2; the importer requires schema 2 (revises D-046's "1"). Room schema unchanged (rows arrive through sync). Carried out by milestone group M12 (M12a contracts, then M12b server and M12c Android in parallel; M6 and M7c after M12c). | UX-8 (owner, 2026-10-18): the household's history has these as large separate groups (Hyra + Amortering 154 rows, Barnomsorg 73, Försäkringar 26, Skatter 19) that were collapsed into Hem and Övrigt; separate categories make analytics and the suggestion meaningful. M3, M5 and M9 landed with ten defaults, so this is a contract change against `main`. | SPEC AC-7.2, §6.2, §9.7, §10.8, §11, §13.3 (S-15), §14.1, §14.1.1, §14.2.4, §14.3.3, §14.3.4 (E-1), §14.7, §14.8 | -| D-055 | 2026-10-18 | The keyword table reaches the phone by a build-time copy: `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords` copy `contracts/category-keywords.csv` into an AGP generated resource directory; a missing file fails the build. Packaged as the Java resource `se/hagstedt/kvitt/data/category-keywords.csv` (and `se/hagstedt/kvitt/fakes/category-keywords.csv` for the fakes, debug only); no committed copy. A `:data` test asserts byte identity with the contract file and the §14.8 rules. Supersedes the "byte-identical copy at `android/data/src/main/resources`" wording of v0.8. | M12c report (`e499107`): a committed copy can drift from the contract file; a build-time copy cannot. | SPEC §11.2 (M12c), §14.2.4, §14.7, §14.8; PLAN-M12c | -| D-056 | 2026-10-18 | `:domain` exposes the pure `parseCategoryKeywords(csv: String): List`: header `keyword,match,default_key,source`, entries in file order, `IllegalArgumentException` on a malformed table; shared by `:data` and `:core:fakes`. | M12c report (`e499107`): two callers needed the same parser; `:domain` is the shared pure module. | SPEC §14.5.6 | -| D-057 | 2026-10-18 | `DefaultCategoryKey` in `:core:model` is declared in AC-7.2 sort order. The Rust wire enum, the OpenAPI `DefaultCategoryKey` enum and the 0002 `CHECK` list the ten v1 keys, then `housing`, `insurance`, `children`, `fees`. Wire values are unaffected. | M12b (`2e2ebe7`) and M12c (`e499107`) reports: the declaration orders differ by side; enums compare as sets on the wire. | SPEC §14.2.4 | -| D-058 | 2026-10-18 | The server's test harnesses seed the two users after the fourteen category seeds, so they have `server_version` 15 and 16. `contracts/examples/E-7-me/response.json` shows 15 and 16 (was 11 and 12, from schema 1's ten seeds), and the E-7 test compares `server_version` with the example again instead of treating it as volatile. | M12b report (`2e2ebe7`): migration 0002 moved the users' versions; the example should match what the server produces. | SPEC §14.3.5; `contracts/examples/E-7-me/response.json`, `server/crates/kvitt-server/tests/core_api/me.rs` | +| D-055 | 2026-10-18 | The keyword table reaches the phone by a build-time copy: `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords` copy `contracts/category-keywords.csv` into an AGP generated resource directory; a missing file fails the build. Packaged as the Java resource `se/hagstedt/kvitt/data/category-keywords.csv` (and `se/hagstedt/kvitt/fakes/category-keywords.csv` for the fakes, debug only); no committed copy. A `:data` test asserts byte identity with the contract file and the §14.8 rules. Supersedes the "byte-identical copy at `android/data/src/main/resources`" wording of v0.8. | M12c report (`b09f322`): a committed copy can drift from the contract file; a build-time copy cannot. | SPEC §11.2 (M12c), §14.2.4, §14.7, §14.8; PLAN-M12c | +| D-056 | 2026-10-18 | `:domain` exposes the pure `parseCategoryKeywords(csv: String): List`: header `keyword,match,default_key,source`, entries in file order, `IllegalArgumentException` on a malformed table; shared by `:data` and `:core:fakes`. | M12c report (`b09f322`): two callers needed the same parser; `:domain` is the shared pure module. | SPEC §14.5.6 | +| D-057 | 2026-10-18 | `DefaultCategoryKey` in `:core:model` is declared in AC-7.2 sort order. The Rust wire enum, the OpenAPI `DefaultCategoryKey` enum and the 0002 `CHECK` list the ten v1 keys, then `housing`, `insurance`, `children`, `fees`. Wire values are unaffected. | M12b (`6eaccec`) and M12c (`b09f322`) reports: the declaration orders differ by side; enums compare as sets on the wire. | SPEC §14.2.4 | +| D-058 | 2026-10-18 | The server's test harnesses seed the two users after the fourteen category seeds, so they have `server_version` 15 and 16. `contracts/examples/E-7-me/response.json` shows 15 and 16 (was 11 and 12, from schema 1's ten seeds), and the E-7 test compares `server_version` with the example again instead of treating it as volatile. | M12b report (`6eaccec`): migration 0002 moved the users' versions; the example should match what the server produces. | SPEC §14.3.5; `contracts/examples/E-7-me/response.json`, `server/crates/kvitt-server/tests/core_api/me.rs` | | D-059 | 2026-10-18 | UX-9 accepted as a contract change: `ExpenseEditorOptions.frequentCategoryIds: Map>` (per active account up to 5 active categories by use count in that account over the whole Room copy, ties by `sort_order`, unused ones left out; read once on open and on an account change) and `CategoryRepository.deletable(id): Flow` (false for a default or a category used by a non-deleted expense or template, AC-7.6; the server stays the authority), with the readings, UI text choice and fallbacks of §14.2.4 "Changes in v0.9". Implemented in `:core:model`, `:data` and `:core:fakes` by the new milestone M12d, run in parallel with M6; M7c and M7f consume it, and M7a to M7f depend on M6 and M12d. | U2 (`docs/ux/proposed-decisions.md` UX-9): S-7's `CategoryQuickRow` and S-15's category editor have binding states the UI contract did not carry. | SPEC §11.1, §11.2, §11.3, §14.2.4, §14.7; PLAN-M12d, PLAN-M7c, PLAN-M7f | -| D-060 | 2026-10-18 | `ExpenseEditorOptions.frequentCategoryIds` holds only categories actually used in each active account, so a list may have fewer than five entries or be empty. Filling up to five from `sort_order` and placing the selected category fifth stays in the editor (UX-9, `docs/ux/design-spec.md` §3.7). Not a contract change. As landed by M12d (`bdba9d3`). | M12d report: D-059's "at most 5, unused ones left out" left open whether the repository or the editor fills the row; keeping the fill in the editor keeps the repository a plain usage count. | SPEC §14.2.4 ("Readings fixed in v0.10"), §11.2, §11.3 | +| D-060 | 2026-10-18 | `ExpenseEditorOptions.frequentCategoryIds` holds only categories actually used in each active account, so a list may have fewer than five entries or be empty. Filling up to five from `sort_order` and placing the selected category fifth stays in the editor (UX-9, `docs/ux/design-spec.md` §3.7). Not a contract change. As landed by M12d (`557af7e`). | M12d report: D-059's "at most 5, unused ones left out" left open whether the repository or the editor fills the row; keeping the fill in the editor keeps the repository a plain usage count. | SPEC §14.2.4 ("Readings fixed in v0.10"), §11.2, §11.3 | | D-061 | 2026-10-18 | `frequentCategoryIds` has a key for every active account (an empty list when nothing was used there) and no key for an archived account. After use count (descending) and `sort_order` (ascending), ties fall back to the category id (ascending), so the order is identical in Room and in the fakes. | M12d report: without a last tie-break two categories with equal count and `sort_order` (custom categories can share one) could order differently in `:data` and `:core:fakes`. | SPEC §14.2.4 | | D-062 | 2026-10-18 | `CategoryRepository.deletable(id)` returns false for an unknown or already deleted id. Archived categories follow the same AC-7.6 rule as active ones. In `:core:fakes`, `delete` and `deletable` share one rule function. | M12d report: D-059 did not cover unknown, deleted or archived ids; one rule function keeps the fake from offering a delete it then refuses. | SPEC §14.2.4, AC-7.6 | -| D-063 | 2026-10-18 | Charts (S-11) are drawn with Compose `Canvas` in `:core:design`, not Vico. No chart dependency is added; ~~Vico stays in `android/gradle/libs.versions.toml` unused and can be removed in a later cleanup.~~ (Vico removed from the catalog on 2026-10-18, `2b4e21c`; SPEC v0.14.) Takes the fallback §6.2 already allowed. As landed by M6 (`3080804`). | M6 report: Vico 3.3.1 cannot give design-spec §3.10's one focusable TalkBack node per bar with its own sentence and tap target, 2 dp gaps between segments, the 10/4 corner radii on the whole bar (not per segment; 4/2 since D-097) and a y-axis gutter that grows at 200 % font scale. | SPEC §6.2 (Charts, module table), §11.2, §11.3 | +| D-063 | 2026-10-18 | Charts (S-11) are drawn with Compose `Canvas` in `:core:design`, not Vico. No chart dependency is added; ~~Vico stays in `android/gradle/libs.versions.toml` unused and can be removed in a later cleanup.~~ (Vico removed from the catalog on 2026-10-18, `a312758`; SPEC v0.14.) Takes the fallback §6.2 already allowed. As landed by M6 (`7730a31`). | M6 report: Vico 3.3.1 cannot give design-spec §3.10's one focusable TalkBack node per bar with its own sentence and tap target, 2 dp gaps between segments, the 10/4 corner radii on the whole bar (not per segment; 4/2 since D-097) and a y-axis gutter that grows at 200 % font scale. | SPEC §6.2 (Charts, module table), §11.2, §11.3 | | D-064 | 2026-10-18 | Material 3 Expressive against material3 1.4.0: `ShortNavigationBar` is public and used. Not usable, with the fallback taken: `MaterialShapes` (Cookie4, Clover4Leaf, Sunny, Cookie9) → rounded 30 %, rounded 16 dp, circle, circle; `ButtonGroup` → `SingleChoiceSegmentedButtonRow` styled per the design spec; `LoadingIndicator` → `CircularProgressIndicator`; `LinearWavyProgressIndicator` → `LinearProgressIndicator`; medium extended FAB → `ExtendedFloatingActionButton` 56 dp high, radius 20; Expressive button sizes → `Button` with `heightIn`; segmented list items → Kvitt's own `SegmentedGroup`; Expressive slider → Kvitt's own split bar with a knob; `MotionScheme.expressive()` and the `MaterialTheme(motionScheme)` overload are internal → Material components keep the default motion and Kvitt components use the design-spec §2.11 springs in `KvittMotion`; `Shapes.largeIncreased` and the other increased shapes are internal → held in `KvittShapes`. Fulfils design-spec §6 open point 5. | M6 report: the design spec names Expressive APIs that material3 1.4.0 (pinned since M2b) does not expose publicly; the spec's rule is a fallback without a design change. | SPEC §6.2 (UI); `docs/ux/design-spec.md` §3.1, §6 | | D-065 | 2026-10-18 | Formatting tables are code: Swedish and English month and weekday names are tables inside `DateFormatter`, not platform locale data, so JVM tests and phone output are identical. Chart axis labels have no dots ("maj, jun, … okt"); list dates keep the Swedish dot ("3 okt.", "25 sep."). File sizes are SI megabytes (10^6 bytes) with one decimal, rounded half up, U+00A0 before "MB" ("8,4 MB", "8.4 MB"). | M6 report: platform locale data differs between the JVM used by tests and Android, and §9.8 gave no file-size format for the update banner. | SPEC §9.8 | | D-066 | 2026-10-18 | Golden layout: ~~one Roborazzi golden per component sheet × light/dark × sv/en (`__.png`), plus a 200 % font-scale variant for long states;~~ (revised by D-098: three variants `sv_light`, `sv_dark`, `en_light` plus `sv_light_200`, at 1x density) the content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Feature modules (M7a to M7f) follow the same layout. | M6 report: one layout keeps the goldens reviewable and the feature agents' screenshot tests consistent. | SPEC §6.2 (Screenshot tests) | | D-067 | 2026-10-18 | The English default name of the category `restaurants` is "Dining" (was "Restaurants"); Swedish "Restaurang & café" stays. Changed in AC-7.2, `docs/ux/strings.csv` and `android/core/design/src/main/res/values/strings.xml` (`category_default_restaurants`) together. | Owner approved: "Restaurants" clips at 11 sp in the six-column `CategoryQuickRow`, and design-spec §3.7 allows no ellipsis there. | SPEC AC-7.2, §13.4; `docs/ux/strings.csv` | | D-068 | 2026-10-18 | New shared string `ledger_settlement_label` ("Payment" / "Betalning", `module=core:design`) as the type label on the second line of a settlement row (design-spec §3.5), added to `docs/ux/strings.csv` beside the other `ledger_settlement_*` rows and to `:core:design`'s `values/strings.xml` and `values-sv/strings.xml`. Also: `SettlementRow` titles may wrap to 2 lines (3 at 200 % font scale), and a chart whose range has no expenses shows only the 0 baseline with no gridline values (design-spec §3.10). | M6 report: `settle_detail_title` belongs to `:feature:settle` and `:feature:account` cannot use it (D-017); the wrap and the empty-chart rendering were not stated in the design spec. | SPEC §13.3 (S-5, S-11), §13.4; `docs/ux/strings.csv` | -| D-069 | 2026-10-18 | S-11 readings. Total-card caption " · Hushållet" for the household measure and " · N veckor" in week granularity; for my share the label is "Totalt · Min del", so the caption does not repeat it. Month presets show whole-month ranges ("maj–okt 2026"), custom ranges and week granularity show days. Legend and stacking order: categories by `sortOrder`, accounts in `AccountRepository.all()` order; the order of `AnalyticsState.groups` is unspecified and the UI sorts. "Loading" means `SyncSummary.lastSuccessAt == null` and shows skeleton cards; the design spec has no offline or loading frame for S-11/S-12, so offline looks like loaded. | M7e report (`6ac711d`): the design spec left caption wording, order and the loading condition open. | SPEC §13.3 (S-11), §14.2.4 ("Readings fixed in v0.11") | -| D-070 | 2026-10-18 | S-12 readings. A week title follows `docs/ux/strings.csv` ("Vardag, vecka 41"), not "v. 41"; a category or account over the whole range is titled ", ". No error state on S-11 or S-12 (analytics are local, AC-14.1); S-12 has no loading state. | M7e report (`6ac711d`): design spec and strings disagreed on the week title; the error and loading states of §13.3 do not apply to local analytics. | SPEC §13.3 (S-11, S-12) | +| D-069 | 2026-10-18 | S-11 readings. Total-card caption " · Hushållet" for the household measure and " · N veckor" in week granularity; for my share the label is "Totalt · Min del", so the caption does not repeat it. Month presets show whole-month ranges ("maj–okt 2026"), custom ranges and week granularity show days. Legend and stacking order: categories by `sortOrder`, accounts in `AccountRepository.all()` order; the order of `AnalyticsState.groups` is unspecified and the UI sorts. "Loading" means `SyncSummary.lastSuccessAt == null` and shows skeleton cards; the design spec has no offline or loading frame for S-11/S-12, so offline looks like loaded. | M7e report (`ea7d494`): the design spec left caption wording, order and the loading condition open. | SPEC §13.3 (S-11), §14.2.4 ("Readings fixed in v0.11") | +| D-070 | 2026-10-18 | S-12 readings. A week title follows `docs/ux/strings.csv` ("Vardag, vecka 41"), not "v. 41"; a category or account over the whole range is titled ", ". No error state on S-11 or S-12 (analytics are local, AC-14.1); S-12 has no loading state. | M7e report (`ea7d494`): design spec and strings disagreed on the week title; the error and loading states of §13.3 do not apply to local analytics. | SPEC §13.3 (S-11, S-12) | | D-071 | 2026-10-18 | Navigation wiring. The top-level screens S-3, S-4, S-11 and S-13 render their own `KvittNavigationBar` and take `onNavigate: (KvittDestination) -> Unit`; the `:app` scaffold draws no second bar; the `NavHost` switches top-level destinations from those lambdas. Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID`/`ARG_ACCOUNT_ID`, `SettleArgs.SETTLEMENT_ID`/`ACCOUNT_ID`, `DrillDownArgs.KEYS`, settings `"categoryId"`/`"templateId"`), read through `SavedStateHandle`. Result snackbars of S-7, S-9 and account archiving are shown by the screen returned to ~~(Home through its `snackbarHostState`)~~ (revised by D-095: six screens host the shared `snackbarHostState` in their own scaffold, `:app` has no overlay host); the account-archived snackbar has an undo action (`common_undo`). | M7a, M7b, M7e, M7f reports and M8's brief: the features built the bar and the argument keys themselves; M8 needs one rule to wire them. | SPEC §13.2, §11.2 (M8), §11.3 (M8) | -| D-072 | 2026-10-18 | A new recurring template's "Dag i månaden" defaults to today's day of the month; the start month is the current month if that day is today or later, else the next month (so by default the current month). | M7f report (`a54eb7c`): AC-9.8 gave the start-month rule but no default day. | SPEC AC-9.8, §13.3 (S-19) | -| D-073 | 2026-10-18 | ~~"Sök efter uppdatering" (S-22) reports "Du har den senaste versionen" when `UpdateState` is still `None` after `check()`; a network failure cannot be told apart because `check()` returns nothing. Accepted for v1; "give `UpdateRepository.check()` a result" is a contract request for a later contract pass.~~ (Revised by D-092: `check()` returns `UpdateCheck`, and S-22 tells up to date from a failure.) | M7f report (`a54eb7c`): the UI contract has no result for a manual check. | SPEC AC-16.6 (new), §13.3 (S-22), §14.2.4, §11.3 | -| D-074 | 2026-10-18 | `FieldError.field` values are the model property names `amountOre`, `description`, `notes`, `date`, `split`, `accountId`, `categoryId`, `allocations`, as `:core:fakes` and `:data` emit them. Editing a settlement opens with its saved rows counted as user-edited, so changing the amount keeps them; "Föreslå igen" re-proposes. ~~The S-9 info line on untouched accounts derives them from Home balances and may be slightly off while editing (balances include the settlement being edited).~~ (Revised by D-093: S-9 reads `SettleState.untouched`, nets without the settlement being edited.) | M7c (`1e42f76`) and M7d (`082b8dc`) reports: field names were not fixed; AC-11.6 did not say whether an edit re-proposes; `SettleState` has no untouched-accounts list. | SPEC §14.2.4, AC-11.6, §13.3 (S-9), §11.3 | -| D-075 | 2026-10-18 | The quick-row fill of D-060 lives in `:core:design`'s `quickRowCategories(categories, frequent, selected)`; editors pass the raw `frequentCategoryIds[accountId]`; an empty or short list is filled from `sort_order`, and the selected category takes fifth place when absent. Replaces `docs/ux/design-spec.md` §3.7's interim "selected first, then sort_order". | M6b (`3a1bd14`): S-7 and S-19 both need the fill; one helper keeps them identical. | SPEC §14.2.4, §13.3 (S-7), §11.2 (M6b) | -| D-076 | 2026-10-18 | Category item labels (quick row and grid) take up to two centred lines; longer custom names end the second line in "…"; single long words still hyphenate ("Entertain-ment"); TalkBack reads the full name. "No ellipsis" (design-spec §3.7) still holds for the default names. | M6b (`3a1bd14`): custom category names (up to 40 code points, §9.6) can be longer than two lines; the default names still fit without an ellipsis. | SPEC §13.3 (S-7); `docs/ux/design-spec.md` §3.7 | -| D-077 | 2026-10-18 | Day ranges in captions and summaries are written out with the year ("24 augusti–17 oktober 2026", "1–30 november 2026", "28 december 2025–3 januari 2026"); the period chip and the S-12 title keep the short form "24 aug.–17 okt.". Extends D-065. | M6b (`3a1bd14`, `DateFormatter.dayRangeLong`): M7e needed a long form for the total caption. | SPEC §9.8, §13.3 (S-11) | -| D-078 | 2026-10-18 | A stored amount opens for editing with both decimals ("1 247,60", "12 400,00"); ratio percent fields keep the trimmed form ("60", "60,5"). Note: `viewerPercentOf` now reads basis points (bug fix, no rule change). | M6b (`3a1bd14`, `AmountInput.of` and `AmountInput.percent`, replacing M7c's own `moneyInput`): money is always shown with two decimals (§9.8), percents are not money. | SPEC §9.6 | -| D-079 | 2026-10-18 | New shared keys `common_ok`, `common_undo`, `editor_split_full_on_you` ("All on you" / "Allt på dig"; `ledger_settlement_label` was D-068). The module keys proposed by M7b to M7f are recorded in `docs/ux/strings.csv` as built: `analytics_granularity`, `analytics_measure`, `analytics_group_by`, `analytics_range_apply`; `settle_row_amount`, `settle_detail_delete`, `settle_detail_sync_now`; `expense_detail_sync_now`; `home_archived_expanded`, `home_archived_collapsed`; `settings_category_archived_snackbar`, `settings_category_unarchived_snackbar`, `settings_recurring_summary_none`, `settings_expanded`, `settings_collapsed`, `settings_partner_missing`. `strings.csv` has 578 rows, header included. | M7b to M7f reports and M6b (`3a1bd14`): §13.4 requires a decisions entry for strings not in the file. | SPEC §13.4; `docs/ux/strings.csv` | -| D-080 | 2026-10-18 | "Synka nu" (the missing states of S-8 and S-10, and S-16) is an online-only action, disabled with "Kräver anslutning" offline. Discard dialogs (S-6, S-7, S-9, S-19) have a title and no body text. | M7c report (`1e42f76`): §9.9 did not list "Sync now", which needs the server; the discard dialogs' body text was not fixed. | SPEC §9.9, §13.3 (S-6, S-7, S-8, S-9, S-10, S-16, S-19) | -| D-081 | 2026-10-18 | Both variants bind the `:core:model` interfaces to `:data` through `android/app/src/main/.../di/AppDataModule.kt`, which includes `DataBindingsModule` (D-041). `DebugFakesModule` (`72f21b2`) and the release-only data module are deleted; no Hilt module lives in `src/debug` or `src/release`. Fakes live only in test source sets and feature previews; `:app`'s unit tests run on a plain `Application`. Revises D-041 ("debug binds the fakes"). (Proposed by M8 as D-085, renumbered.) | M8 report (`369e516`): the debug app must pair against the development server (D-001), so debug needs the real `:data` graph. | SPEC §6.2, §11.2 (M8), §11.3 (M8) | -| D-082 | 2026-10-18 | A network security config cannot express IP ranges, so the debug config (`android/app/src/debug/res/xml/network_security_config.xml`) allows cleartext to every host and the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses only) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. The release config is in `src/main` (cleartext off, system anchors); the pinned-root config of §10.5.3 is in `src/releasePinnedRoot/res`, added to the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended. (M8's D-086, renumbered.) | M8 report (`369e516`): §8.5 asked for cleartext "to the hosts listed in AC-2.8", which the config format cannot list. | SPEC §8.5, §10.5.3, AC-2.8 | -| D-083 | 2026-10-18 | FCM registration by Firebase Installation ID (firebase-messaging 26.0.0 through the Firebase BoM 35.0.2, google-services plugin 4.5.0). `register()` runs whenever the phone is paired and at each app start while paired; `onRegistered(fid)` sends the id as `fcm_token` with E-17; when the device is revoked the app calls `unregister()`. Needs the manifest meta-data `firebase_messaging_installation_id_enabled`. ~~No server change: FCM HTTP v1 `message.token` accepts installation ids during Firebase's transition. Follow-up request for the server (§11.3 M12e list): send the id in the `fid` field.~~ (Revised by D-096: the server sends the id in `message.fid`, `c694f78`.) (M8's D-087, renumbered.) | M8 report (`369e516`): firebase-messaging 26 registers an app instance by its Firebase Installation ID; AC-13.7 described a rotating FCM token. | SPEC AC-13.7, §6.2, §14.6, §11.3 | -| D-084 | 2026-10-18 | An invite link (`kvitt://invite` or `https://icehack.asuscomm.com:8443/i/`, declared with `autoVerify="false"`) opened while the phone is paired is ignored; the app never re-pairs over live data. (M8's D-088, renumbered.) | M8 report (`369e516`): AC-2.6 did not say what a link does on a paired phone; re-pairing would discard the local copy and its pending changes. | SPEC AC-2.6 | -| D-085 | 2026-10-18 | Push handling: an expedited WorkManager job calls `syncForPush` (waited up to 20 s), then builds the notification text from Room. A tap on the generic notification (sync failed, or entity or actor missing locally) opens Home, not the detail; deletions and `sync_batch` also open Home; an unknown `type` or `v` ≠ `1` only syncs. Channel `activity`; notification id = hash of `entity_id`, `sync_batch` uses id `1`. (M8's D-089, renumbered.) | M8 report (`369e516`): AC-13.6 and §14.6 did not say where a generic notification leads or which fixed id `sync_batch` uses. | SPEC AC-13.6, §14.6 | +| D-072 | 2026-10-18 | A new recurring template's "Dag i månaden" defaults to today's day of the month; the start month is the current month if that day is today or later, else the next month (so by default the current month). | M7f report (`c4ab35f`): AC-9.8 gave the start-month rule but no default day. | SPEC AC-9.8, §13.3 (S-19) | +| D-073 | 2026-10-18 | ~~"Sök efter uppdatering" (S-22) reports "Du har den senaste versionen" when `UpdateState` is still `None` after `check()`; a network failure cannot be told apart because `check()` returns nothing. Accepted for v1; "give `UpdateRepository.check()` a result" is a contract request for a later contract pass.~~ (Revised by D-092: `check()` returns `UpdateCheck`, and S-22 tells up to date from a failure.) | M7f report (`c4ab35f`): the UI contract has no result for a manual check. | SPEC AC-16.6 (new), §13.3 (S-22), §14.2.4, §11.3 | +| D-074 | 2026-10-18 | `FieldError.field` values are the model property names `amountOre`, `description`, `notes`, `date`, `split`, `accountId`, `categoryId`, `allocations`, as `:core:fakes` and `:data` emit them. Editing a settlement opens with its saved rows counted as user-edited, so changing the amount keeps them; "Föreslå igen" re-proposes. ~~The S-9 info line on untouched accounts derives them from Home balances and may be slightly off while editing (balances include the settlement being edited).~~ (Revised by D-093: S-9 reads `SettleState.untouched`, nets without the settlement being edited.) | M7c (`0b140aa`) and M7d (`7380e50`) reports: field names were not fixed; AC-11.6 did not say whether an edit re-proposes; `SettleState` has no untouched-accounts list. | SPEC §14.2.4, AC-11.6, §13.3 (S-9), §11.3 | +| D-075 | 2026-10-18 | The quick-row fill of D-060 lives in `:core:design`'s `quickRowCategories(categories, frequent, selected)`; editors pass the raw `frequentCategoryIds[accountId]`; an empty or short list is filled from `sort_order`, and the selected category takes fifth place when absent. Replaces `docs/ux/design-spec.md` §3.7's interim "selected first, then sort_order". | M6b (`b24c8b5`): S-7 and S-19 both need the fill; one helper keeps them identical. | SPEC §14.2.4, §13.3 (S-7), §11.2 (M6b) | +| D-076 | 2026-10-18 | Category item labels (quick row and grid) take up to two centred lines; longer custom names end the second line in "…"; single long words still hyphenate ("Entertain-ment"); TalkBack reads the full name. "No ellipsis" (design-spec §3.7) still holds for the default names. | M6b (`b24c8b5`): custom category names (up to 40 code points, §9.6) can be longer than two lines; the default names still fit without an ellipsis. | SPEC §13.3 (S-7); `docs/ux/design-spec.md` §3.7 | +| D-077 | 2026-10-18 | Day ranges in captions and summaries are written out with the year ("24 augusti–17 oktober 2026", "1–30 november 2026", "28 december 2025–3 januari 2026"); the period chip and the S-12 title keep the short form "24 aug.–17 okt.". Extends D-065. | M6b (`b24c8b5`, `DateFormatter.dayRangeLong`): M7e needed a long form for the total caption. | SPEC §9.8, §13.3 (S-11) | +| D-078 | 2026-10-18 | A stored amount opens for editing with both decimals ("1 247,60", "12 400,00"); ratio percent fields keep the trimmed form ("60", "60,5"). Note: `viewerPercentOf` now reads basis points (bug fix, no rule change). | M6b (`b24c8b5`, `AmountInput.of` and `AmountInput.percent`, replacing M7c's own `moneyInput`): money is always shown with two decimals (§9.8), percents are not money. | SPEC §9.6 | +| D-079 | 2026-10-18 | New shared keys `common_ok`, `common_undo`, `editor_split_full_on_you` ("All on you" / "Allt på dig"; `ledger_settlement_label` was D-068). The module keys proposed by M7b to M7f are recorded in `docs/ux/strings.csv` as built: `analytics_granularity`, `analytics_measure`, `analytics_group_by`, `analytics_range_apply`; `settle_row_amount`, `settle_detail_delete`, `settle_detail_sync_now`; `expense_detail_sync_now`; `home_archived_expanded`, `home_archived_collapsed`; `settings_category_archived_snackbar`, `settings_category_unarchived_snackbar`, `settings_recurring_summary_none`, `settings_expanded`, `settings_collapsed`, `settings_partner_missing`. `strings.csv` has 578 rows, header included. | M7b to M7f reports and M6b (`b24c8b5`): §13.4 requires a decisions entry for strings not in the file. | SPEC §13.4; `docs/ux/strings.csv` | +| D-080 | 2026-10-18 | "Synka nu" (the missing states of S-8 and S-10, and S-16) is an online-only action, disabled with "Kräver anslutning" offline. Discard dialogs (S-6, S-7, S-9, S-19) have a title and no body text. | M7c report (`0b140aa`): §9.9 did not list "Sync now", which needs the server; the discard dialogs' body text was not fixed. | SPEC §9.9, §13.3 (S-6, S-7, S-8, S-9, S-10, S-16, S-19) | +| D-081 | 2026-10-18 | Both variants bind the `:core:model` interfaces to `:data` through `android/app/src/main/.../di/AppDataModule.kt`, which includes `DataBindingsModule` (D-041). `DebugFakesModule` (`a41f240`) and the release-only data module are deleted; no Hilt module lives in `src/debug` or `src/release`. Fakes live only in test source sets and feature previews; `:app`'s unit tests run on a plain `Application`. Revises D-041 ("debug binds the fakes"). (Proposed by M8 as D-085, renumbered.) | M8 report (`cb832a7`): the debug app must pair against the development server (D-001), so debug needs the real `:data` graph. | SPEC §6.2, §11.2 (M8), §11.3 (M8) | +| D-082 | 2026-10-18 | A network security config cannot express IP ranges, so the debug config (`android/app/src/debug/res/xml/network_security_config.xml`) allows cleartext to every host and the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses only) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. The release config is in `src/main` (cleartext off, system anchors); the pinned-root config of §10.5.3 is in `src/releasePinnedRoot/res`, added to the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended. (M8's D-086, renumbered.) | M8 report (`cb832a7`): §8.5 asked for cleartext "to the hosts listed in AC-2.8", which the config format cannot list. | SPEC §8.5, §10.5.3, AC-2.8 | +| D-083 | 2026-10-18 | FCM registration by Firebase Installation ID (firebase-messaging 26.0.0 through the Firebase BoM 35.0.2, google-services plugin 4.5.0). `register()` runs whenever the phone is paired and at each app start while paired; `onRegistered(fid)` sends the id as `fcm_token` with E-17; when the device is revoked the app calls `unregister()`. Needs the manifest meta-data `firebase_messaging_installation_id_enabled`. ~~No server change: FCM HTTP v1 `message.token` accepts installation ids during Firebase's transition. Follow-up request for the server (§11.3 M12e list): send the id in the `fid` field.~~ (Revised by D-096: the server sends the id in `message.fid`, `f56dcbe`.) (M8's D-087, renumbered.) | M8 report (`cb832a7`): firebase-messaging 26 registers an app instance by its Firebase Installation ID; AC-13.7 described a rotating FCM token. | SPEC AC-13.7, §6.2, §14.6, §11.3 | +| D-084 | 2026-10-18 | An invite link (`kvitt://invite` or `https://icehack.asuscomm.com:8443/i/`, declared with `autoVerify="false"`) opened while the phone is paired is ignored; the app never re-pairs over live data. (M8's D-088, renumbered.) | M8 report (`cb832a7`): AC-2.6 did not say what a link does on a paired phone; re-pairing would discard the local copy and its pending changes. | SPEC AC-2.6 | +| D-085 | 2026-10-18 | Push handling: an expedited WorkManager job calls `syncForPush` (waited up to 20 s), then builds the notification text from Room. A tap on the generic notification (sync failed, or entity or actor missing locally) opens Home, not the detail; deletions and `sync_batch` also open Home; an unknown `type` or `v` ≠ `1` only syncs. Channel `activity`; notification id = hash of `entity_id`, `sync_batch` uses id `1`. (M8's D-089, renumbered.) | M8 report (`cb832a7`): AC-13.6 and §14.6 did not say where a generic notification leads or which fixed id `sync_batch` uses. | SPEC AC-13.6, §14.6 | | D-086 | 2026-10-18 | E-3's response gains `invite_kind` (the accepted invite's kind). `SessionRepository.pair` returns `OnlineResult`; a wire value the app does not know is read as `device`. S-2 follows only a `partner` pairing; a member pairing a second phone with a `device` invite goes to Home. Fakes: `FakeScenario.pairingInviteKind`. | M7a and M8 reports (§11.3 list): without the kind, S-2 showed after any first `member` pairing, device invites included. | SPEC AC-2.4, AC-2.10, §13.2, §13.3 (S-2), §14.2.4, §14.3.4 (E-3); `contracts/examples/E-3-invites-accept` | | D-087 | 2026-10-18 | E-12's link is `/i/?from=`. E-4 takes the optional `from` and passes it only into `kvitt://invite?server&code&from`; it is never shown on the page (AC-2.5 still holds). An invalid value (not UTF-8, a control character, more than 40 code points) is dropped and the page still opens. `InviteLink` (`PastedInvite.adminName`), `AppLink.Invite`, `PairingScreen(initialAdminName)` and `PairingViewModel.prefill` carry the name; S-1 shows `onboarding_code_help` with it. Older links without a name still parse. | M7a report (§11.3 list): `onboarding_code_help` could not be filled, so S-1 showed only the generic line. | SPEC AC-2.5, AC-2.6, §13.3 (S-1), §14.3.4 (E-4, E-12); `contracts/examples/E-12-create-invite`, `contracts/openapi.yaml` | | D-088 | 2026-10-18 | `SyncSummary.lastAttemptFailed`: true when the last finished cycle started after the last success (or none ever succeeded) and no cycle is running. `SyncStatusPill`, `syncPillVariant` and `syncLineText` drop their `failed` parameter; S-16 derives "failed" as online plus `lastAttemptFailed`. | M6 and M7b reports (§11.3 list): Home passed `failed = false`, so "Sync failed" never showed. | SPEC §13.3 (S-4, S-16), §14.2.4, §11.3 (M6) | @@ -100,16 +100,25 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-093 | 2026-10-18 | `SettleState.untouched: List`: accounts not in the rows where the payer is owed, with nets that exclude the settlement being edited, in `AccountRepository.all()` order; empty for a per-account settle-up. Computed in `:data` and in the fakes. S-9 uses it and no longer reads Home nets. Revises D-074's S-9 reading. | M7d report (D-074, §11.3 list): Home balances include the settlement being edited. | SPEC §13.3 (S-9), §14.2.4 | | D-094 | 2026-10-18 | The D-027 re-stamp as implemented: when `sync_state.clock_skew` is set at the start of a cycle, every pending expense and settlement whose `updated_at` (or a tombstone's `deleted_at`) is later than the phone's clock is re-stamped to now, in one Room transaction before pushing; rows stamped no later than now are untouched. Room schema unchanged. Revises D-027. | M5 report (D-027, §11.3 list): the re-stamp was not in `:data`. | SPEC §14.4.5 step 3 | | D-095 | 2026-10-18 | `PushEvent` lives in `:core:model` (`model/Push.kt`); `:adapter:fcm` keeps `PushListener` and `PushMapping`. Six screens host the shared `snackbarHostState` in their own scaffold (Home, AccountDetail, ExpenseDetail, SettlementDetail, DrillDown, SyncStatus); `:app` has no overlay host. `ExpenseEditorScreen.onSaved(ExpenseId, @StringRes Int?)`, `onDeleted()` without argument on S-7 and S-8; `:app` shows `snackbar_expense_deleted`. Launcher icon: adaptive, vectors only, `mipmap-anydpi/ic_launcher.xml`, background `#9C5238` (light primary), foreground and monochrome `receipt_long` mark at 48 dp, manifest `icon` and `roundIcon`; lint `MissingApplicationIcon` cleared. Revises D-071. | M8 report (§11.3 list): `PushEvent` was in the adapter, the overlay host could cover a FAB or the bottom bar, S-7 and S-8 handed over resolved strings, the app had no launcher icon. | SPEC §6.2, §13.2, §13.3 (S-7, S-8), §14.2.4, §14.6, §11.3 | -| D-096 | 2026-10-18 | The server sends the device's stored `fcm_token` (the Firebase Installation ID of D-083) in the FCM HTTP v1 `message.fid` field, no longer in the deprecated `message.token`. No database or wire change: the `devices.fcm_token` column and the E-17 field keep the name `fcm_token`. `contracts/examples/push/*.json` use `fid` (a contract-file change, hence this entry and SPEC v0.14). Landed `c694f78`. Revises D-083's "no server change". | The FCM HTTP v1 reference (projects.messages, last updated 2026-09-08, read 2026-10-18) deprecates `token` with "Use fid instead" and defines `fid` as the Firebase Installation ID target; `token`, `fid`, `topic` and `condition` are mutually exclusive. Live probe from the arm64 image (`docs/spikes/pi-image-arm64.md`): FCM answered "unregistered" for a made-up id, so the field is accepted. | SPEC §14.6, AC-13.7, §11.3; `server/crates/kvitt-server/src/push/fcm.rs`, `contracts/examples/push/` | -| D-097 | 2026-10-18 | S-11 chart bars have top corners 4 dp and bottom corners 2 dp on the whole bar, replacing top 10, bottom 4. The 2 dp gaps between segments and the clip of the whole bar (not per segment) are unchanged. Assumption recorded: the owner's "4 outer / 2 inner" means top / bottom. Landed by M8b (`f0575d3`) in `KvittShapes` and `Charts.kt`, goldens re-recorded. | First phone test: the owner found the bar tops too round. | SPEC §6.2 (Charts), §13.4; `docs/ux/design-spec.md` §2.9 (shapes table), §3.10 | -| D-098 | 2026-10-18 | Golden layout: three variants per component sheet or screen state, `_sv_light`, `_sv_dark`, `_en_light` (English covers string length, dark covers the theme; no `en_dark`), plus `_sv_light_200` for the long states; rendered at 1x density through the Robolectric qualifier `w411dp-hdp-mdpi`, so a full screen is 411 × 891 px. Every committed PNG, the `docs/ux/` mock-ups included, is compressed losslessly with `oxipng -o 4 --strip safe` (`7c453d8`). Landed by M8c (`b481fab`). Revises D-066 (the compose-once configuration context stays). | The owner wants a small repository for the home Gitea server: the goldens went from 1143 PNGs / 131 MB to 865 / 26 MB, the mock-ups from 36.8 to 26.0 MB. | SPEC §6.2 (Screenshot tests), §13.4; `android/README.md` | -| D-099 | 2026-10-18 | S-22 About has no open-source licences row: `AboutScreen`'s `onOpenLicences` (and `AboutActions.onOpenLicences`, the `:app` call site) and the string `settings_about_licences` are removed, also from `docs/ux/strings.csv` (577 rows). Landed by M8c (`64f5706`). Closes OQ-5. | Owner answer to OQ-5 on 2026-10-18: no licences screen in v1, hide the button. | SPEC §11.3, §12 (OQ-5), §13.3 (S-22), §13.4; `docs/ux/design-spec.md` S-22 | -| D-100 | 2026-10-18 | No SSH from the Mac to the Pis. M2c-pi produces a self-contained bundle `infra/pi/` (compose file, Caddyfile, env template, install, deploy, bootstrap and backup scripts, a README checklist) that the owner copies to the Kvitt Pi and runs there. The OQ-3 values (Kvitt Pi address, Home Assistant Pi address, SSH port and user, certificate directory, SSH add-on) become placeholders the owner fills in the bundle's `.env`; OQ-3 is answered by this. Where the spec has a Mac script act on the Pi (`infra/deploy.sh`, `infra/publish-apk.sh`), the step becomes a bundle script the owner runs; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. Agents never need allowances for the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. Revises D-023 and the M2c-pi plan; D-001 stands. Fulfilled by D-103 to D-106 (bundle landed `5e027df`). | Owner decision 2026-10-18: no agent access to the home servers; the Pi is set up once, by hand, from a checklist. | SPEC §10 intro, §10.1, §10.10, §11.1, §11.2 (M2c-pi, M10), §11.3, §12 (OQ-3); PLAN-M2c-pi | +| D-096 | 2026-10-18 | The server sends the device's stored `fcm_token` (the Firebase Installation ID of D-083) in the FCM HTTP v1 `message.fid` field, no longer in the deprecated `message.token`. No database or wire change: the `devices.fcm_token` column and the E-17 field keep the name `fcm_token`. `contracts/examples/push/*.json` use `fid` (a contract-file change, hence this entry and SPEC v0.14). Landed `f56dcbe`. Revises D-083's "no server change". | The FCM HTTP v1 reference (projects.messages, last updated 2026-09-08, read 2026-10-18) deprecates `token` with "Use fid instead" and defines `fid` as the Firebase Installation ID target; `token`, `fid`, `topic` and `condition` are mutually exclusive. Live probe from the arm64 image (`docs/spikes/pi-image-arm64.md`): FCM answered "unregistered" for a made-up id, so the field is accepted. | SPEC §14.6, AC-13.7, §11.3; `server/crates/kvitt-server/src/push/fcm.rs`, `contracts/examples/push/` | +| D-097 | 2026-10-18 | S-11 chart bars have top corners 4 dp and bottom corners 2 dp on the whole bar, replacing top 10, bottom 4. The 2 dp gaps between segments and the clip of the whole bar (not per segment) are unchanged. Assumption recorded: the owner's "4 outer / 2 inner" means top / bottom. Landed by M8b (`328c466`) in `KvittShapes` and `Charts.kt`, goldens re-recorded. | First phone test: the owner found the bar tops too round. | SPEC §6.2 (Charts), §13.4; `docs/ux/design-spec.md` §2.9 (shapes table), §3.10 | +| D-098 | 2026-10-18 | Golden layout: three variants per component sheet or screen state, `_sv_light`, `_sv_dark`, `_en_light` (English covers string length, dark covers the theme; no `en_dark`), plus `_sv_light_200` for the long states; rendered at 1x density through the Robolectric qualifier `w411dp-hdp-mdpi`, so a full screen is 411 × 891 px. Every committed PNG, the `docs/ux/` mock-ups included, is compressed losslessly with `oxipng -o 4 --strip safe` (`7c453d8`). Landed by M8c (`75b549f`). Revises D-066 (the compose-once configuration context stays). | The owner wants a small repository for the home Gitea server: the goldens went from 1143 PNGs / 131 MB to 865 / 26 MB, the mock-ups from 36.8 to 26.0 MB. | SPEC §6.2 (Screenshot tests), §13.4; `android/README.md` | +| D-099 | 2026-10-18 | S-22 About has no open-source licences row: `AboutScreen`'s `onOpenLicences` (and `AboutActions.onOpenLicences`, the `:app` call site) and the string `settings_about_licences` are removed, also from `docs/ux/strings.csv` (577 rows). Landed by M8c (`a0c25b0`). Closes OQ-5. | Owner answer to OQ-5 on 2026-10-18: no licences screen in v1, hide the button. | SPEC §11.3, §12 (OQ-5), §13.3 (S-22), §13.4; `docs/ux/design-spec.md` S-22 | +| D-100 | 2026-10-18 | No SSH from the Mac to the Pis. M2c-pi produces a self-contained bundle `infra/pi/` (compose file, Caddyfile, env template, install, deploy, bootstrap and backup scripts, a README checklist) that the owner copies to the Kvitt Pi and runs there. The OQ-3 values (Kvitt Pi address, Home Assistant Pi address, SSH port and user, certificate directory, SSH add-on) become placeholders the owner fills in the bundle's `.env`; OQ-3 is answered by this. Where the spec has a Mac script act on the Pi (`infra/deploy.sh`, `infra/publish-apk.sh`), the step becomes a bundle script the owner runs; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. Agents never need allowances for the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. Revises D-023 and the M2c-pi plan; D-001 stands. Fulfilled by D-103 to D-106 (bundle landed `2476667`). | Owner decision 2026-10-18: no agent access to the home servers; the Pi is set up once, by hand, from a checklist. | SPEC §10 intro, §10.1, §10.10, §11.1, §11.2 (M2c-pi, M10), §11.3, §12 (OQ-3); PLAN-M2c-pi | | D-101 | 2026-10-18 | The release keystore `kvitt-release.jks` (created with the D-024 `keytool` command) lives at the repository root of the main checkout, git-ignored by the `*.jks` pattern of `.gitignore` and never committed; `android/local.properties` references it by absolute path in `kvitt.release.storeFile` with the other three `kvitt.release.*` keys. The owner keeps a copy of the file and its password in their password manager. Revises D-024 ("outside the repository"). | Owner decision 2026-10-18; the `*.jks` ignore rule already keeps the file out of git. | SPEC §10.6, §11.2 (M10), §11.3; PLAN-M10 | -| D-102 | 2026-10-18 | S-7 editor options as fixed rows. Below the category quick row, account, date, payer and split are four full-width rows in a fixed order (Konto, Datum, Betalt av, Delning) instead of a wrapping chip row, so no option moves when a value changes. Rows form a segmented group (outer corners 24, inner 8, gap 3) in surface-container-high (secondary-container while the split section is open), each at least 48 dp and tappable in full. A row has a leading element in a 24 dp box, a label (label-large, muted) in a column as wide as the widest label, the value (body-large, one line, ellipsis) and the trailing affordance at the right edge (expand_more/expand_less, swap_horiz for the payer). At ≥ 1.5× font scale the label sits over the value, which takes up to two lines, and every row keeps the two-line height. TalkBack sentences are unchanged. S-9 and S-19 keep their chips (S-19 follows in M8e, D-107). Also: connected buttons get 8 dp side padding so 'Household' fits half of S-11's filter bar at 1x. Landed by M8d (`3c68594`): `EditorOptionRows` in `:core:design`, `KvittDimens.connectedButtonSide`. | Owner finding on S-7, 2026-10-18: the chips moved when a value changed. | SPEC §13.3 (S-7), §13.4; `docs/ux/design-spec.md` §3.7, §3.10 | -| D-103 | 2026-10-18 | The M2c-pi bundle `infra/pi/`: `compose.yaml` (was `infra/compose.yaml`), `.env.example` (was `infra/env.example`), `Caddyfile`, and the on-Pi scripts `install.sh` (idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secrets with their modes, adds `gitea.lan` to `insecure-registries`, sets the volume owners, starts the stack), `deploy.sh [--skip-backup]` (backs up before every deploy, because the Pi cannot tell whether a tag carries a migration; keeps `/opt/kvitt/previous-tag` for rollback), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, helpers in `lib.sh`. APK path: `infra/publish-apk.sh` on the Mac writes `infra/pi/out/apk/` (`version_code` = `git rev-list --count HEAD`, `version_name` = `YYYY.MM.DD-` in Europe/Stockholm, passed as `-Pkvitt.versionCode` / `-Pkvitt.versionName`; copies `mapping.txt` as `kvitt-.mapping.txt`; refuses a dirty tree and an unsigned APK); the owner `scp`s it to `/opt/kvitt/apk/`; `install-apk.sh` copies it into the `data` volume, writing `latest.json` last and keeping the newest 5; mappings stay outside the volume. `.env` keys grow by `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (read by `install.sh` only) and the informational `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. The §11.2 gate becomes `docker compose -f infra/pi/compose.yaml config`. Landed `5e027df`. Fulfils D-100. | M2c-pi report: D-100 left the layout, script names and the image and APK paths to M2c-pi. | SPEC §10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2 (M2c-pi), §11.3, §14.7; `infra/README.md` | -| D-104 | 2026-10-18 | Images `${KVITT_REGISTRY}/kvitt-server`, `kvitt-cert-sync`, `kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/`). `.gitea/workflows/build-images.yaml` follows the owner's working example: runner `host` (a Raspberry Pi 3, 1 GB; builds run on the runner itself), Alpine `apk add git docker-cli docker-cli-buildx`, a plain-HTTP buildx builder created once and reused, secret `kvitt_cicd`; triggers on `server/**`, `infra/cert-sync/**`, `infra/backup/**`, `.gitea/workflows/**` and `workflow_dispatch`; tags `v1.MINOR.PATCH` (the next patch after the newest tag) plus `latest`, the git tag pushed afterwards; `--build-arg CARGO_BUILD_JOBS=1` (honoured by `server/Dockerfile` since `965bb5d`; first build duration unknown). `infra/build-images.sh` on the Mac is the fallback (default tag the short SHA; builds locally and prints the push commands when `infra/secrets/registry.env` is absent). Both sidecars schedule with supercronic instead of busybox crond (crond runs no job as non-root, tested); the cert-sync image adds `openssl`. Pinned versions (2026-10-18): `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`, `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6`, openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; Docker Engine from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is 32-bit only), `docker-ce` 5:29.8.2. | M2c-pi report: the owner's example workflow pushes under the owner path with flat image names; busybox crond did not run jobs as a non-root user. | SPEC §10.1, §10.2, §10.3, §12 (OQ-1); `.gitea/workflows/build-images.yaml`, `infra/README.md` "Versions" | +| D-102 | 2026-10-18 | S-7 editor options as fixed rows. Below the category quick row, account, date, payer and split are four full-width rows in a fixed order (Konto, Datum, Betalt av, Delning) instead of a wrapping chip row, so no option moves when a value changes. Rows form a segmented group (outer corners 24, inner 8, gap 3) in surface-container-high (secondary-container while the split section is open), each at least 48 dp and tappable in full. A row has a leading element in a 24 dp box, a label (label-large, muted) in a column as wide as the widest label, the value (body-large, one line, ellipsis) and the trailing affordance at the right edge (expand_more/expand_less, swap_horiz for the payer). At ≥ 1.5× font scale the label sits over the value, which takes up to two lines, and every row keeps the two-line height. TalkBack sentences are unchanged. S-9 and S-19 keep their chips (S-19 follows in M8e, D-107). Also: connected buttons get 8 dp side padding so 'Household' fits half of S-11's filter bar at 1x. Landed by M8d (`bacbd18`): `EditorOptionRows` in `:core:design`, `KvittDimens.connectedButtonSide`. | Owner finding on S-7, 2026-10-18: the chips moved when a value changed. | SPEC §13.3 (S-7), §13.4; `docs/ux/design-spec.md` §3.7, §3.10 | +| D-103 | 2026-10-18 | The M2c-pi bundle `infra/pi/`: `compose.yaml` (was `infra/compose.yaml`), `.env.example` (was `infra/env.example`), `Caddyfile`, and the on-Pi scripts `install.sh` (idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secrets with their modes, adds `gitea.lan` to `insecure-registries`, sets the volume owners, starts the stack), `deploy.sh [--skip-backup]` (backs up before every deploy, because the Pi cannot tell whether a tag carries a migration; keeps `/opt/kvitt/previous-tag` for rollback), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, helpers in `lib.sh`. APK path: `infra/publish-apk.sh` on the Mac writes `infra/pi/out/apk/` (`version_code` = `git rev-list --count HEAD`, `version_name` = `YYYY.MM.DD-` in Europe/Stockholm, passed as `-Pkvitt.versionCode` / `-Pkvitt.versionName`; copies `mapping.txt` as `kvitt-.mapping.txt`; refuses a dirty tree and an unsigned APK); the owner `scp`s it to `/opt/kvitt/apk/`; `install-apk.sh` copies it into the `data` volume, writing `latest.json` last and keeping the newest 5; mappings stay outside the volume. `.env` keys grow by `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (read by `install.sh` only) and the informational `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. The §11.2 gate becomes `docker compose -f infra/pi/compose.yaml config`. Landed `2476667`. Fulfils D-100. | M2c-pi report: D-100 left the layout, script names and the image and APK paths to M2c-pi. | SPEC §10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2 (M2c-pi), §11.3, §14.7; `infra/README.md` | +| D-104 | 2026-10-18 | Images `${KVITT_REGISTRY}/kvitt-server`, `kvitt-cert-sync`, `kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/`). `.gitea/workflows/build-images.yaml` follows the owner's working example: runner `host` (a Raspberry Pi 3, 1 GB; builds run on the runner itself), Alpine `apk add git docker-cli docker-cli-buildx`, a plain-HTTP buildx builder created once and reused, secret `kvitt_cicd`; triggers on `server/**`, `infra/cert-sync/**`, `infra/backup/**`, `.gitea/workflows/**` and `workflow_dispatch`; tags `v1.MINOR.PATCH` (the next patch after the newest tag) plus `latest`, the git tag pushed afterwards; `--build-arg CARGO_BUILD_JOBS=1` (honoured by `server/Dockerfile` since `b4d9467`; first build duration unknown). `infra/build-images.sh` on the Mac is the fallback (default tag the short SHA; builds locally and prints the push commands when `infra/secrets/registry.env` is absent). Both sidecars schedule with supercronic instead of busybox crond (crond runs no job as non-root, tested); the cert-sync image adds `openssl`. Pinned versions (2026-10-18): `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`, `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6`, openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; Docker Engine from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is 32-bit only), `docker-ce` 5:29.8.2. | M2c-pi report: the owner's example workflow pushes under the owner path with flat image names; busybox crond did not run jobs as a non-root user. | SPEC §10.1, §10.2, §10.3, §12 (OQ-1); `.gitea/workflows/build-images.yaml`, `infra/README.md` "Versions" | | D-105 | 2026-10-18 | `backup` runs as uid 10001 on the Pi too, so it can open the WAL database (`sqlite3 .backup` needs the `-shm` file the server owns); `/opt/kvitt/secrets/rclone.conf` mode 0600 owned 10001:10001. `cert-sync` stays uid 1000 with `ha_ssh_key`, `ha_known_hosts` 0600 owned by 1000. `backup.sh` uses a temporary copy of the read-only `rclone.conf` so rclone can save refreshed tokens. `install.sh` chowns `/data`, `/data/apk`, `/backups` to 10001 and `/certs` to 1000 once. Reconciles SPEC §10.2; revises D-023 for `rclone.conf`. | M2c-pi report: uid 1000 could not back up the live database without loosening the server's files. | SPEC §10.2, §10.9; `infra/README.md` "Backup uid" | | D-106 | 2026-10-18 | `sync.sh` always requests the `tar -C -cf - fullchain.pem privkey.pem` stream, so both `authorized_keys` variants (A restricted, B plain) work unchanged; it refuses a key not matching its certificate and a certificate expiring before the installed one; an unreachable Caddy admin API at first start is not a failure (Caddy loads the files when it starts). `backup.sh` uploads every pending `kvitt-*` / `pictures-*` file including leftovers of failed runs, confirms each by name and size with `rclone lsf`, deletes the local copies only after all are confirmed, and writes `last-success` only after that. | M2c-pi report: the add-on's support for `authorized_keys` options is unknown; rehearsal of renew, refuse and failed-upload cases on the Mac. | SPEC §10.5.1, §10.9 | -| D-107 | 2026-10-18 | S-19's options as fixed rows, extending D-102. The recurring template editor replaces its wrapping chips row (Konto · Betalt av · Delning) with `EditorOptionRows`: three full-width rows in the fixed order Konto, Betalt av, Delning, with no Datum row. They look and behave as in S-7: label column, value on one line with an ellipsis, label over value with a two-line value at ≥ 1.5× font scale, and Delning in secondary-container while the split section is open. A row's place and size never depend on its value. The schedule fields stay in the "Schema" section. TalkBack sentences are unchanged. A Robolectric layout test asserts identical row bounds for short and long values at 100 % and 200 %. The chip composables stay in `:core:design` for S-9 (`EditorChipRow`, `DateChip`); `AccountChip`, `PayerChip`, `SplitChip` were removed in `4755fe0`. Landed by M8e (`15397e4`). | Owner finding (D-102): chips moved when a value changed; S-19 had the same chips row. | SPEC §13.3 (S-19); `docs/ux/design-spec.md` §3.7, S-19 | -| D-108 | 2026-10-18 | Navigation is instant. Every destination change (pushed screens, bottom-destination switches, S-7 and S-9 and the other editors, onboarding and the session gate) has no enter, exit, pop or size transition (`EnterTransition.None` / `ExitTransition.None`, no `SizeTransform`, set once on the app's `NavHost`, `NavTransitions` in `:app`). Replaces the Navigation bullet of design-spec §2.11. Motion inside a screen (ripples, state layers, expanding sections, FAB extend/collapse, bottom-sheet open, the seesaw and bunting) is unchanged. Not a contract change. Landed by M8f (`ed1b130`). | Owner finding on the S26, 2026-10-18: the default 700 ms fades made the app feel slow. | SPEC §13.2; `docs/ux/design-spec.md` §2.11 | +| D-107 | 2026-10-18 | S-19's options as fixed rows, extending D-102. The recurring template editor replaces its wrapping chips row (Konto · Betalt av · Delning) with `EditorOptionRows`: three full-width rows in the fixed order Konto, Betalt av, Delning, with no Datum row. They look and behave as in S-7: label column, value on one line with an ellipsis, label over value with a two-line value at ≥ 1.5× font scale, and Delning in secondary-container while the split section is open. A row's place and size never depend on its value. The schedule fields stay in the "Schema" section. TalkBack sentences are unchanged. A Robolectric layout test asserts identical row bounds for short and long values at 100 % and 200 %. The chip composables stay in `:core:design` for S-9 (`EditorChipRow`, `DateChip`); `AccountChip`, `PayerChip`, `SplitChip` were removed in `1328afe`. Landed by M8e (`a59abeb`). | Owner finding (D-102): chips moved when a value changed; S-19 had the same chips row. | SPEC §13.3 (S-19); `docs/ux/design-spec.md` §3.7, S-19 | +| D-108 | 2026-10-18 | Navigation is instant. Every destination change (pushed screens, bottom-destination switches, S-7 and S-9 and the other editors, onboarding and the session gate) has no enter, exit, pop or size transition (`EnterTransition.None` / `ExitTransition.None`, no `SizeTransform`, set once on the app's `NavHost`, `NavTransitions` in `:app`). Replaces the Navigation bullet of design-spec §2.11. Motion inside a screen (ripples, state layers, expanding sections, FAB extend/collapse, bottom-sheet open, the seesaw and bunting) is unchanged. Not a contract change. Landed by M8f (`956908a`). | Owner finding on the S26, 2026-10-18: the default 700 ms fades made the app feel slow. | SPEC §13.2; `docs/ux/design-spec.md` §2.11 | +| D-109 | 2026-10-18 | S-16 shows a rejected change's lost version read-only. `Rejection` gains `lost: RejectedVersion?`, with `RejectedVersion(row: LedgerRow, accountName: String?, payer: User?, mode: SplitMode?, shares: List, notes: String, deleted: Boolean)` decoded in `:data` from `sync_rejections.rejected_payload` (the entity as pushed; `row` is the ledger row it would have been, `pending` false; for a settlement `accountName`, `payer` and `mode` are null and `shares` empty). `lost` is null when the payload cannot be decoded or its category or a user is not on the phone. "Öppna" on a rejection opens a dialog "Din version" (`home_sync_lost_title`, body `home_sync_lost_body`) with the read-only ledger row, account, payer, split and notes, the badge "Du tog bort den här" (`home_sync_lost_deleted`) for a refused deletion, and "Visa nuvarande version" (`home_sync_lost_show_current`), which opens S-8 or S-10; without `lost` it opens the current version as before. The `:core:design` ledger rows take a nullable `onClick` (null = read-only). Landed by M12f (`dc7a296`). UI contract change. | AC-coverage audit: S-16's "open a rejected change (read-only)" opened the current editable row, not the lost change. | SPEC §13.3 (S-16), §13.4, §14.2.4, §14.4.6; `docs/ux/design-spec.md` S-16 | +| D-110 | 2026-10-18 | `AboutState` gains `schemaVersion: Int?`. `AboutRepositoryImpl` calls E-1 `/health` once when S-22 opens while online and takes `server_version` and `schema_version` from it; offline or when the call fails, `serverVersion` falls back to the `X-Kvitt-Server-Version` header of the last response and `schemaVersion` is null. S-22 shows " (schema )" (`settings_about_server_version_schema`, "1.1.0 (schema 3)"), the version alone without a schema. Landed by M12f (`dc7a296`). UI contract change. | AC-coverage audit: S-22 read the server version from a response header, while §13.3 says "from `/health`". | SPEC §13.3 (S-22), §13.4, §14.2.4, §14.3.4 (E-1); `docs/ux/design-spec.md` S-22 | +| D-111 | 2026-10-18 | `SettlementRepository.start` and `recompute` return `SettleState?`: null when there is no paired session or the partner has not joined (S-9 is not offered then, AC-5.1). `save` returns `SaveResult.Invalid([FieldError("payer", "validation_failed")])` in that state. No method throws; `:data` has no `checkNotNull`/`requireNotNull` there. Landed by M12f (`dc7a296`). UI contract change. | M12f: `:data` threw (`checkNotNull`/`requireNotNull`) when S-9 was reached without a session or partner; failures are values in the UI contract. | SPEC §4 (AC-5.1), §14.2.4 | +| D-112 | 2026-10-18 | A failed E-17 `PUT /devices/current/push-token` (network error, 429, 5xx) is kept by `PushTokenRetry` in `:app` and sent again after each successful sync until the server accepts it; a newer FID from `onRegistered` replaces the kept one. Landed by M12f (`2bd6b58`), `PushTokenRetryTest`. | M12f: a failed send was otherwise repeated only at the next app start's `register()`. | SPEC §4 (AC-13.7) | +| D-113 | 2026-10-18 | `kvitt-server invite-partner`: what E-12 `POST /invites` with `kind: partner` does for the admin, run on the server host: cancels a pending `partner` invite, creates one by the admin (72 h, same code format), prints the code, the landing link naming the admin (`?from=`, D-087) and the expiry, like `bootstrap`; exit 0 ok, 2 household full, 3 no admin (nothing created); no audit entry (as E-12); the code goes to stdout only. The push sender logs one info line `push sent` per message with `push_type`, `devices_sent`, `devices_targeted` and the last 8 characters of each device id that got it; the per-device line stays at debug. Landed by the server gap pass (`8660b7f`), with CLI tests for `invite-partner`, `migrate`, `healthcheck` and the SIGTERM drain; `tools/contracts-check.py` follows `invite_kind`, `app_version_name` and `fid` (134 checks). Closes the ops request of the M8 phone test. | The M8 end-to-end test needed a second admin device to invite a scripted partner; push delivery was invisible at the default log level. | SPEC §8.7, §10.4, §11.3; `server/README.md`, `infra/README.md` | +| D-114 | 2026-10-18 | Upload and token limits. A picture upload (E-9, AC-4.4) must also be at most 16 000 000 pixels (width × height, for example 4000 × 4000) and decode to at most 64 MB (16 MP × 4 bytes, so a 16-bit image is refused above 8 MP), checked from the image header before decoding with the decoder limits set as well (`MAX_PIXELS`, `MAX_DECODE_ALLOC` in `src/api/pictures.rs`), refused with `image_invalid`; the 8000 px per side and 5 MiB limits stay. `fcm_token` at E-17 is 1 to 512 characters after trimming (`FCM_TOKEN_MAX`); longer is `invalid_request` with field `fcm_token`, like the empty string (D-034); a Firebase Installation ID has 22 characters. Landed by the server overhaul (`baa5806`, `b985ce9`). | Security review M-3: 8000 × 8000 decodes to 256 MB RGBA, above the 160 MiB container. L-4: a device could store a token up to the 1 MiB body limit, sent to FCM in every push. | SPEC §4 (AC-4.4), §9.6, §14.3.4 (E-17) | +| D-115 | 2026-10-18 | Rate-limit token acceptance, revising D-032: a bearer token is accepted only when a response to it is 2xx; a request with a token not yet accepted counts against its client IP's unauthenticated limit before it runs and is refused with `rate_limited` once the IP is over; a 2xx gives that count back; a 401 for an accepted token counts against the IP and withdraws the acceptance; token entries are pruned once their window expires, accepted or not; every 401 counts against the IP exactly once. Landed by the server overhaul (`725901c`), with unit tests per rule and an integration test of random tokens without the protocol header (60 × 426, then 429, the paired device unaffected). | Security review H-1: a random token without `X-Kvitt-Protocol` (426) became accepted for good, bypassed the IP limit and was never pruned, so a stranger could grow the in-memory map without bound. | SPEC §8.6 | +| D-116 | 2026-10-18 | Notifications are posted with `VISIBILITY_PRIVATE` and a public version carrying the generic "Kvitt" / "Open Kvitt to see it" (`notif_generic_title`, `notif_generic_body`), so a secure lock screen shows no description, amount, account or name (new AC-13.9; `NotificationPrivacyTest`). L-6 (a link can prefill any server on S-1) stays as built: S-1 opens its server row expanded when the link's server is not the release default, release builds require `https://`, and a paired phone ignores links (D-084); a confirmation step would need a design state and is not in v1 (OQ-6). Landed by M12f (`2bd6b58`). | Security review L-7 and L-6. | SPEC §4 (AC-2.6, AC-2.8, AC-13.9), §12 (OQ-6) | +| D-117 | 2026-10-18 | Infra security fixes, landed `7fb86f5`. (1) Caddy's access log and error log (`log default`) filter `request>uri` with ``regexp `^(/i/)[^?]+\|([?]).+` `${1}${2}REDACTED` `` (`wrap json`), so invite codes and the `?from=` name never reach a log; `Authorization` stays redacted by default. (2) Deferred response headers `Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"`, `X-Content-Type-Options nosniff`, `X-Frame-Options DENY`, `Referrer-Policy no-referrer`. (3) The `caddy` container runs as `10002:10002` with `group_add` 1000, `read_only`, `cap_drop: [ALL]` plus `cap_add: [NET_BIND_SERVICE]` (the image's binary has that file capability and cannot be exec'd without it), `no-new-privileges`, tmpfs `/config` and `/tmp`; `install.sh` sets `/certs` 0750 1000:1000 and `caddy_data` to 10002 (extends D-105). (4) The development stack mounts `infra/dev/secrets/` (git-ignored), into which `up.sh` copies only `fcm-service-account.json`. (5) `deploy.sh` and `install.sh` print each pulled image's digest and append ` ` to `/opt/kvitt/deployed-digests`, with a WARNING when a recorded reference changed digest; the workflow prints the pushed digests. (6) `kvitt_cicd` needs only `write:package` (the tag push uses the checkout credentials). (7) The plain root key (variant B) for cert-sync is no longer acceptable: variant A (restricted) or a dedicated non-root user. (8) One-off check after the router forward that Caddy's `remote_ip` shows public addresses. | Security review at `18c527f`: L-1, L-2, L-5, L-9, M-2, I-5. | SPEC §8.7, §8.9, §10 intro, §10.2, §10.3, §10.5.1, §10.5.2, §10.10; `infra/pi/`, `infra/dev/`, `.gitea/workflows/build-images.yaml`, `infra/README.md` | diff --git a/docs/ux/design-spec.md b/docs/ux/design-spec.md index 7a1d6a4..fbb1fe5 100644 --- a/docs/ux/design-spec.md +++ b/docs/ux/design-spec.md @@ -666,7 +666,7 @@ Each screen: layout top to bottom, the **binding states** (exactly the frames of * Code typing: uppercase, hyphens inserted after 4 and 8, normalised per §8.3; "Para ihop" enabled when the code has 12 characters, the name is non-blank (first pairing) and the server is a valid URL. IME `Next` from code to name, `Done` on the name runs "Para ihop". * "Klistra in": reads the clipboard; a 12-character code or an invite link (`kvitt://invite?…` or `https://…/i/`) fills code (and server). * Other errors, all inline under the field at fault, button enabled again: network "Kunde inte nå servern. Kontrollera adressen och anslutningen." (under the server row, which expands); `rate_limited` "För många försök. Vänta en stund och försök igen." (under the code); `http://` in release (AC-2.8) "Adressen måste börja med https://" (server). Offline: the button stays enabled and gives the network error (pairing never queues). -* Success: `pair(serverUrl, code, displayName, deviceName = Build.MODEL)` → partner pairing goes to S-2 (AC-2.10), otherwise S-4 in its loading state; after the first full sync the system notification-permission prompt (AC-2.9), preceded by nothing (the rationale string `home_notification_rationale` is used only if Android asks for a rationale). +* Success: `pair(serverUrl, code, displayName, deviceName = Build.MODEL)` → partner pairing goes to S-2 (AC-2.10), otherwise S-4 in its loading state; after the first full sync the system notification-permission prompt (AC-2.9), preceded by nothing (no rationale screen). **TalkBack**: the paste button reads its text "Klistra in" (`onboarding_code_paste`); the code field reads the value group by group; the server row "Serveradress, https://icehack.asuscomm.com:8443, dubbeltryck för att ändra"; errors are announced (live region assertive). @@ -1042,7 +1042,7 @@ Full screen (§13.2), works offline (AC-8.2). The flow and the category rule are | `S-16-sync-clock` | variant clock skew | clock banner first; status "Synkad för 2 h sedan" / "Senaste försöket nekades: klockan" with `sync_problem`; pending rows; no rejection list | | `S-16-sync-empty` | empty | status card "Allt är synkat" (`home_sync_status_all_synced`) over "Senast lyckade synk 13:59"; "Synka nu"; no pending or rejection sections | -**Interactions**: "Öppna" opens the entity's current version (S-8 / S-10; deleted or missing states apply); "Ta bort från listan" → `dismiss` (works offline). The rows update live while a sync runs. +**Interactions**: "Öppna" opens the lost version read-only (D-109) when `Rejection.lost` is set: a dialog with "Din version" (headline-small, `home_sync_lost_title`), "Så här såg det ut på telefonen. Det sparades inte och kan inte ändras här." (body-medium muted, `home_sync_lost_body`), the ledger row it would have been (`ExpenseRow` / `SettlementRow` with a null `onClick`, so it is not tappable), `FactRow`s for account, payer, split and notes where present, the badge "Du tog bort den här" (`delete`, `home_sync_lost_deleted`) with the facts at the deleted alpha when the refused change was a deletion, and "Visa nuvarande version" (`home_sync_lost_show_current`), which opens the current version (S-8 / S-10; deleted or missing states apply); without `lost` (unreadable payload, or its category or a user is not on the phone) "Öppna" opens the current version directly. "Ta bort från listan" → `dismiss` (works offline). The rows update live while a sync runs. **TalkBack**: status card one node; rejection card one node plus its two buttons, read with their visible text ("Öppna", "Ta bort från listan", `home_sync_status_dismiss`). @@ -1132,7 +1132,7 @@ Not a screen: the `UpdateBanner` on S-4 (and S-22) and one dialog. ### S-22 About (`:feature:settings`) -**Layout** (app bar: back, "Om Kvitt"; column gap 10): centred app mark 96 (Sunny, `primary`, `receipt_long` 48), "Kvitt 1.1 (11)" (headline-medium), "Delade utgifter för två" (body-medium muted); `UpdateBanner` when an update is available; group of `FactRow`s: Server (`dns`, URL wraps), Serverversion (`memory`, "1.1.0" or "Okänd" when `/health` failed); no open-source licences row (OQ-5, D-099); "Sök efter uppdatering" (tonal medium, `system_update`, centred; hidden in debug builds, AC-16.5). +**Layout** (app bar: back, "Om Kvitt"; column gap 10): centred app mark 96 (Sunny, `primary`, `receipt_long` 48), "Kvitt 1.1 (11)" (headline-medium), "Delade utgifter för två" (body-medium muted); `UpdateBanner` when an update is available; group of `FactRow`s: Server (`dns`, URL wraps), Serverversion (`memory`, "1.1.0 (schema 3)" from `/health` when S-22 opens online, `settings_about_server_version_schema`; offline or when `/health` fails the last response's server-version header without the schema, "1.1.0", or "Okänd" when none is known; D-110); no open-source licences row (OQ-5, D-099); "Sök efter uppdatering" (tonal medium, `system_update`, centred; hidden in debug builds, AC-16.5). **States** diff --git a/docs/validation/ac-coverage.md b/docs/validation/ac-coverage.md new file mode 100644 index 0000000..560be25 --- /dev/null +++ b/docs/validation/ac-coverage.md @@ -0,0 +1,408 @@ +> Taken at `ed1b130` (main, 2026-10-18, SPEC v0.16; `956908a` after the history rewrite, so the hashes below are pre-rewrite); committed verbatim with SPEC v0.18. Its two S-16 and S-22 mismatches are fixed since (D-109, D-110; SPEC §11.4). + +# AC coverage audit: is Kvitt feature complete against SPEC.md? + +Audited 2026-10-18 at `main` `ed1b130` (SPEC v0.16), read-only: code and tests were read (`git grep`, file reads); no build, no test run, no phone. Scope: SPEC §4 (FR-1 to FR-20, every AC), §9 rules, §13.3 screen actions S-1 to S-22, §14.2.4 UI contract, §14.3.4 endpoints E-1 to E-33, §14.4 sync, §14.6 push, §10.4 server CLI. Aggregates only; no personal data. + +Status meanings: **Done** = implemented in the real path (`:data`, features, `:app`, server crates) and demonstrated by a test, or by the recorded M8 phone check; **Partial** = implemented but a sub-clause or the §4 evidence rule is not met; **Missing** = not present; **Not verifiable without device** = only on-device or on-Pi behaviour (system UI) can prove it. + +## 1. Summary + +| Status | Rows | +|---|---| +| Done | 260 | +| Partial | 5 | +| Missing | 1 | +| Not verifiable without device | 4 | +| **Total** | **270** | + +Per part: server side (Part A, 98 rows): 95 Done, 2 Partial, 1 Missing. Android FR-2 to FR-12 and S-1 to S-10 (Part B, 100 rows): 96 Done, 1 Partial, 3 device-only. Android FR-9, FR-13 to FR-16, FR-19, S-11 to S-22, UI contract (Part C, 72 rows): 69 Done, 2 Partial, 1 device-only. + +Further findings: +- All 33 endpoints are routed (`server/crates/kvitt-server/src/router.rs:31-102`) and tested, and the OpenAPI equality test is in the server gate. +- All six CLI subcommands of §10.4 exist. +- Every S-1 to S-22 action reaches a real destination or repository call in `android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt`; none is wired to a no-op. +- `:core:model` matches the §14.2.4 block exactly, the v0.13 additions included. + +## 2. Verdict + +**Feature complete in code: yes. Feature complete against SPEC §4's acceptance rule: no, not yet.** + +Every functional requirement the app and server must implement is built. What remains falls into three groups: + +**(a) Blocked on the Pi or the first release, which are ops milestones rather than code:** +- AC-18.2 restore drill (Missing). +- AC-18.1 nightly Drive backup observed on the Pi. +- AC-20.1 latency under 100 ms on the Pi. +- AC-16.3 the real PackageInstaller install. + +All belong to M2c-pi or M10 by the milestone table. + +**(b) Two spec mismatches in shipped behaviour:** +- S-16 "open a rejected change (read-only)" opens the current editable row instead of the lost change. +- S-22 reads the server version from a response header, not from `/health`. + +**(c) Test-evidence gaps where the code reads correctly** (§9.2 effect line, AC-2.9, a few `:data` and sync-engine paths). + +None of (b) or (c) blocks the code overhaul or the security review. The (a) items are the M10 gate itself. + +Blockers for "feature complete": +1. AC-18.2 restore drill report. +2. S-16 read-only rejected-change view: implement it, or have a docs decision accept the summary line. +3. S-22 server-version source: fix the code, or amend the spec. + +The first release (M10) additionally needs AC-18.1, AC-20.1 and AC-16.3 shown on the Pi and phones. + +## 3. Prioritised gaps (Missing and Partial first, then device-only, then evidence and robustness) + +1. **AC-18.2 Restore drill (Missing).** `docs/validation/` does not exist, and no drill has been run; the procedure is in `infra/RESTORE.md`. Smallest change: once the Pi stack runs, or earlier on the dev stack as a rehearsal, follow `infra/RESTORE.md`: + - restore the latest backup; + - run `kvitt-server rotate-epoch`; + - let both phones sync and confirm the epoch recovery; + - write `docs/validation/restore-drill.md` with a "Results (owner's phone)" section. + + Owner: M10 (first release), as an ops and docs agent plus the owner's taps. +2. **S-16 "open a rejected change (read-only)" (Partial).** Evidence: `android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt` (RejectionCard, around line 274). The tap opens the ordinary editable S-8 or S-10 for the entity id, which holds the server's winning row (or the "missing" state). The lost local version in `sync_rejections.rejected_payload` is never shown, so "what was lost" is only the one-line summary. Smallest change: a read-only sheet on S-16 that renders the rejected payload. That needs a `Rejection` field, for example a list of display fields, so it is a contract change (§14.8) across `:core:model`, `:data`, `:core:fakes` and `:feature:home`. The alternative is a docs decision that the summary plus opening the current row satisfies §13.3. Owner: an M12-style contract pass, or a docs agent. +3. **S-22 server version source (Partial).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt:336-345` reads the `X-Kvitt-Server-Version` header of the last response (`data/.../api/KvittApi.kt:241`), so the value is empty until a request reaches the server after process start. §13.3 says "from `/health`". Smallest change: call E-1 once when S-22 opens, in `AboutRepositoryImpl` (`:data`, no contract change), or amend the §13.3 wording. Owner: `:data`, or a docs agent. +4. **AC-18.1 Nightly Drive backup (Partial).** The code is complete: 03:30 cron in `infra/backup/Dockerfile`, `rclone copy --immutable` in `infra/backup/backup.sh`. It has never run on the Pi. Smallest change: after the M2c-pi bundle is deployed, list the Drive folder the next morning and record the result in the restore-drill report. Owner: M2c-pi, run by the owner, then M10. +5. **AC-20.1 Health under 100 ms on the Pi (Partial).** Function and test are done (`tests/skeleton/routes.rs` health tests); the latency is not measured. Smallest change: a timing loop against `/health` on the Pi, with the aggregate written into `docs/validation/first-release.md`. Owner: M10. +6. **§9.2 Effect line (Partial: evidence).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt:124` `effectFor` reads correctly, but no `:data` test covers its four cases; the screenshots render the fakes. Smallest change: one `RepositoriesTest` case with four expenses asserting `Lent`, `Borrowed`, `PaidForYourself` and `NotInvolved`. Owner: `:data` (code-overhaul phase). +7. **AC-16.3 PackageInstaller and "Install unknown apps" on the device (device-only).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt:177-209`; the size and SHA-256 checks are unit-tested. Smallest change: verify during M10 with a published signed APK, one owner yes/no. Owner: M10. +8. **AC-2.9 Notification permission after the first sync (device-only, with no unit test).** `android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt:60-66`. Smallest change: an `:app` unit test with a fake `SyncRepository`, plus an owner yes/no on the next fresh pairing. Owner: `:app`. +9. **AC-4.3 and AC-4.6 Photo picker and offline cached pictures (device-only).** Smallest change: two owner yes/no taps (pick a picture in Profile; in airplane mode, the pictures still show). Owner: orchestrator HUMAN steps. +10. **AC-2.6 Stale invite held while paired (note).** A link opened while paired is ignored on screen but kept in `AppViewModel.invite`, and it prefills S-1 if the phone later becomes unpaired or revoked. Smallest change: drop it in `AppViewModel.onLink` while the session is `Paired`, with a unit test. Owner: `:app`. +11. **AC-13.7 Push-token robustness (note).** A failed E-17 PUT is logged and not retried until Firebase calls `onRegistered` again, so a device could stay without pushes. Smallest change: re-send the stored FID at each paired start or after the first successful sync. Owner: `:app` push or `:adapter:fcm`. +12. **AC-19.1 Enforcement (note).** Nothing in the gate rejects a string literal in feature code; a grep finds none today. Smallest change: a literal check in `build-logic`'s `ArchitectureCheckTask`. Owner: build-logic. +13. **Missing tests on existing code (not AC gaps):** + - `SyncEngineTest`: the 429 retry, and the D-040 whole-request refusal (400, 413, 409, 422 lead to the pull plus `last_error_code`). + - `:data`: `CategoryRepositoryImpl.reorder` (one PATCH per changed `sort_order`), and the recurring `toWire` splits per mode. + - The WorkManager unique names and policies. + - The server CLI: `healthcheck`, `migrate`, and the 10 s SIGTERM drain of `serve`. + + Owners: `:data`, and a server plan (code-overhaul phase). +14. **AC-5.1 Home gate without a partner (note).** `KvittApp.kt:236-240` has no test; S-3 itself has screenshots and ViewModel tests. Low priority. + +For the code overhaul and security review, not AC items: +- `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt:49-50` uses `checkNotNull`, which throws in main code when there is no session or no partner. The UI gate prevents it today. +- HANDOFF's ops request for a partner-invite `kvitt-server` subcommand would need a spec change first, because §10.4's subcommand list is exhaustive. + +## 4. TODO(spec), TODO and FIXME in sources + +`git grep -n -E 'TODO|FIXME'` over `android/`, `server/`, `infra/` and `tools/`: + +| file:line | text | note | +|---|---|---| +| `android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt:72` | `TODO(spec): not stated in category-suggestion.md` | CS-5 final tie-break (lower category id). SPEC §9.11 CS-5 now states it, so the marker is stale | +| `tools/contracts-check.py:326` | `TODO(spec), category-suggestion.md does not say.` | Same tie rule, also stale | + +There is no other TODO or FIXME in android, server, infra or tools. The `XXX` hits are mktemp templates (`infra/cert-sync/sync.sh:36`, `infra/pi/lib.sh:22`, `infra/publish-apk.sh:59`) and a doc comment (`server/crates/kvitt-server/src/api/invites.rs:68`). + +## 5. Coverage tables + +### Part A: server side (kvitt-server, kvitt-domain, kvitt-import, infra) + +Read at `ed1b130`. Paths are relative to `server/crates/kvitt-server/` unless they start with `server/`, `infra/` or `docs/`. Test evidence is given as `file::test_fn`. No build was run; the evidence comes from reading the code and the tests. Every route of §14.3.4 is registered in `src/router.rs:31-102`, and `tests/skeleton/routes.rs::every_route_is_registered_and_implemented` checks this. + +#### Endpoints (§14.3.4) + +| id | requirement (one line) | status | evidence (file:line or test fn name) | note | +|---|---|---|---|---| +| E-1 | `GET /health`: status, versions, db check; 503 `degraded` | Done | src/router.rs:32, src/api/health.rs; skeleton/routes.rs::health_reports_ok_with_versions, ::health_is_503_degraded_when_the_database_is_gone | the under-100 ms timing on the Pi is not measured (AC-20.1) | +| E-2 | `GET /setup/status` `{initialized}` | Done | src/router.rs:33, src/api/setup.rs; skeleton/routes.rs::setup_status_follows_the_admin_user; core_api/invites.rs::e3_admin_setup_creates_the_admin_and_e2_turns_initialized | | +| E-3 | `POST /invites/accept` with token, invite_kind, user, device, household; errors | Done | src/router.rs:34, src/api/invites.rs:234-; core_api/invites.rs::e3_* (6 tests) | | +| E-4 | `GET /i/{code}` landing HTML, sv/en, `from`, 404 | Done | src/router.rs:35-37, src/api/landing.rs:203-207; core_api/landing.rs::e4_* (4 tests); sync_api/serve.rs::empty_invite_code_is_the_html_404_page | | +| E-5 | `GET /apk/latest`, `not_found` before publishing | Done | src/router.rs:38, src/api/apk.rs; core_api/apk.rs::e5_not_found_until_published_then_read_on_every_request | | +| E-6 | `GET /apk/files/{file_name}` | Done | src/router.rs:39; core_api/apk.rs::e6_serves_existing_files_matching_the_pattern_only | | +| E-7 | `GET /me` | Done | src/router.rs:43, src/api/me.rs; core_api/me.rs::e7_returns_user_device_household_and_public_url | | +| E-8 | `PATCH /me` display name | Done | src/router.rs:43; core_api/me.rs::e8_changes_the_own_display_name_with_a_new_server_version, ::e8_invalid_request_and_validation_failed | | +| E-9 | `PUT /me/picture` (5 MiB layer) | Done | src/router.rs:44-49, src/api/pictures.rs:39-43; core_api/pictures.rs::e9_* (4 tests) | | +| E-10 | `DELETE /me/picture` with `-removed` marker | Done | src/api/pictures.rs:255-273; core_api/pictures.rs::e10_removes_idempotently_and_the_next_upload_continues_the_count | | +| E-11 | `GET /users/{id}/picture?size=` | Done | src/router.rs:50, src/api/pictures.rs:281-289; core_api/pictures.rs::e11_not_found_and_invalid_request, ::e9_crops_resizes_and_reencodes_and_e11_serves_both_sizes | | +| E-12 | `POST /invites` (admin), link with `?from=` | Done | src/router.rs:51-54, src/api/invites.rs:369-387; core_api/invites.rs::e12_partner_invite_matches_the_example, ::e12_device_invite_for_either_user_and_its_errors | | +| E-13 | `GET /invites`, pending only | Done | src/api/invites.rs:429; core_api/invites.rs::e13_lists_pending_invites_only | | +| E-14 | `DELETE /invites/{id}`, idempotent | Done | src/api/invites.rs:473; core_api/invites.rs::e14_cancel_is_idempotent_and_its_errors | | +| E-15 | `GET /devices`, admin all / member own | Done | src/api/devices.rs:31; core_api/devices.rs::e15_admin_sees_all_devices_member_own_ordered_by_created_at | | +| E-16 | `DELETE /devices/{id}`, `cannot_revoke_current_device` | Done | src/api/devices.rs:61-72; core_api/devices.rs::e16_* (3 tests) | | +| E-17 | `PUT /devices/current/push-token` | Done | src/api/devices.rs:100-110; core_api/devices.rs::e17_sets_and_clears_the_fcm_token, ::e17_invalid_request | | +| E-18 | `POST /accounts` | Done | src/router.rs:65, src/api/accounts.rs; core_api/accounts.rs::e18_* (3 tests) | | +| E-19 | `PATCH /accounts/{id}` | Done | core_api/accounts.rs::e19_renames_and_changes_the_ratio_like_the_example, ::e19_errors | | +| E-20/E-21 | archive and unarchive an account, idempotent | Done | src/router.rs:70-77; core_api/accounts.rs::e20_e21_archive_and_unarchive_are_idempotent | | +| E-22 | `DELETE /accounts/{id}`, `account_not_empty` | Done | core_api/accounts.rs::e22_soft_deletes_an_empty_account, ::e22_account_not_empty_for_expense_settlement_or_template | | +| E-23 | `POST /categories` | Done | core_api/categories.rs::e23_creates_with_sort_order_max_plus_10, ::e23_invalid_request_and_validation_failed | | +| E-24 | `PATCH /categories/{id}` (rename, reset, reorder) | Done | core_api/categories.rs::e24_renames_a_default_and_reorders_like_the_example_then_resets_the_name, ::e24_errors | | +| E-25/E-26 | archive and unarchive a category | Done | core_api/categories.rs::e25_e26_archive_and_unarchive | | +| E-27 | `DELETE /categories/{id}` | Done | core_api/categories.rs::e27_deletes_unused_custom_categories_only, ::e27_category_in_use_by_expense_or_template | | +| E-28 | `POST /recurring-templates`, server mints the id, due period generated before reply | Done | src/api/recurring.rs; core_api/recurring.rs::e28_* (4 tests); core_api/generation.rs::catch_up_generates_every_due_period_on_create_with_audit_and_push | | +| E-29 | `PATCH /recurring-templates/{id}` | Done | core_api/recurring.rs::e29_pauses_like_the_example_and_edits_fields, ::e29_errors | | +| E-30 | `DELETE /recurring-templates/{id}` | Done | core_api/recurring.rs::e30_soft_deletes_and_then_not_found | | +| E-31 | `GET /sync` pull | Done | src/router.rs:100, src/sync/pull.rs; sync_api/pull.rs (7 tests) | | +| E-32 | `POST /sync` push | Done | src/sync/push.rs; sync_api/push_rules.rs (25 tests) | | +| E-33 | `POST /settlements/proposal` | Done | src/router.rs:101, src/api/proposal.rs; core_api/proposal.rs::e33_* (4 tests) | | +| OpenAPI | the generated document equals `contracts/openapi.yaml` | Done | tests/openapi_contract.rs::generated_document_equals_contract | | + +#### CLI (§10.4) + +| id | requirement (one line) | status | evidence (file:line or test fn name) | note | +|---|---|---|---|---| +| CLI-serve | migrations, scheduler, push sender, HTTP; SIGTERM drain 10 s; exit 0 | Done | src/cli/mod.rs:95-131, :25 | no automated test of the SIGTERM drain; used on the dev stack | +| CLI-bootstrap | FR-1; exit 0, or 2 when already initialized | Done | src/cli/bootstrap.rs:27-74; core_api/cli.rs::bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_refuses | | +| CLI-pair-device | `--user`; exit 0, or 3 for an unknown user | Done | src/cli/pair_device.rs:19-64; same cli.rs test (lines 77-98) | | +| CLI-rotate-epoch | new random epoch on stdout; exit 0 | Done | src/cli/rotate_epoch.rs; sync_api/serve.rs::rotate_epoch_sets_a_new_random_epoch_and_exits_0 | | +| CLI-healthcheck | GET 127.0.0.1:/health; exit 0 on 200, else 1 | Done | src/cli/healthcheck.rs:16-59; infra/pi/compose.yaml:65-66, infra/dev/compose.yaml:52-53 | no unit or integration test of the subcommand itself | +| CLI-migrate | runs migrations only; exit 0 | Done | src/cli/mod.rs:88-93 | the CLI path is untested; `db::migrate` is tested in skeleton/migration.rs | + +#### Acceptance criteria (server side) + +| id | requirement (one line) | status | evidence (file:line or test fn name) | note | +|---|---|---|---|---| +| AC-1.1 | bootstrap prints the code, link and expiry; exit 0 | Done | src/cli/bootstrap.rs:42-49; core_api/cli.rs::bootstrap_prints_… | | +| AC-1.2 | users exist: `already initialized` on stderr, exit 2 | Done | src/cli/bootstrap.rs:65-67; cli.rs lines 67-75 | | +| AC-1.3 | rerunning cancels the previous admin_setup invite | Done | src/cli/bootstrap.rs:35; cli.rs line 61 | | +| AC-1.4 | accepting admin_setup creates the admin, the device and a token | Done | core_api/invites.rs::e3_admin_setup_creates_the_admin_and_e2_turns_initialized | | +| AC-1.5 | `/setup/status` false, then true | Done | skeleton/routes.rs::setup_status_follows_the_admin_user | | +| AC-1.6 | `pair-device --user` creates a device invite | Done | core_api/cli.rs lines 77-98 | | +| AC-2.1 (server) | partner invite: `household_full`; a new one cancels the pending one | Done | src/api/invites.rs:385-387; core_api/invites.rs::e3_invite_invalid_for_a_cancelled_code_and_one_hour_before_expiry_it_works, ::e12_device_invite_for_either_user_and_its_errors | | +| AC-2.3 | single use, 72 h, uniform `invite_invalid` | Done | src/api/invites.rs:33, :178; core_api/invites.rs::e3_invite_invalid_for_unknown_expired_used_cancelled_and_malformed_codes | | +| AC-2.4 (server) | accept returns token, invite_kind, user, device, household; partner creates the member | Done | src/api/invites.rs:234-347; core_api/invites.rs::e3_partner_creates_the_member_and_device_pairs_an_existing_user | | +| AC-2.5 | landing page sv/en, buttons, grouped code, `from` passthrough, no-referrer, no-store, 404 | Done | src/api/landing.rs:203-207; core_api/landing.rs (4 tests) | | +| AC-2.12 | more than 10 failed accepts per IP per 60 min give `rate_limited` | Done | src/api/rate_limit.rs; core_api/rate_limit.rs::ten_failed_invite_accepts_lock_the_ip_for_the_rest_of_the_hour | | +| AC-3.2 (server) | revoke rules: admin any, member own, never the current device | Done | src/api/devices.rs:61-72; core_api/devices.rs::e16_* | | +| AC-3.3 (server) | revoked device gets 401 `device_revoked` | Done | src/auth.rs:127-136; core_api/devices.rs::e16_admin_revokes_another_device_which_then_gets_device_revoked | the phone side is out of this part | +| AC-3.4 | a revoked device gets no pushes | Done | src/push/sender.rs:87,94 (`revoked_at IS NULL`); push_fcm/fcm.rs::recipients_are_the_other_users_live_devices_with_a_token_recurring_goes_to_both | | +| AC-3.5 | never deleted; `revoked_at` set, `fcm_token` cleared | Done | src/api/devices.rs:72; devices.rs test lines 60-70 | | +| AC-4.4 | image rules: types, 5 MiB, 8000 px, EXIF, crop, JPEG 85 at 512 and 128, strip, flatten, version | Done | src/api/pictures.rs:3-43,73; core_api/pictures.rs::e9_* | | +| AC-5.2 | `household_incomplete` for accounts, templates, expense and settlement mutations | Done | src/api/rows.rs:105-110; accounts.rs::e18_household_incomplete_before_the_partner_joins; recurring.rs::e28_household_incomplete; push_rules.rs::step2_household_incomplete_fails_the_whole_request | | +| AC-6.2 | names unique, case-insensitive after trimming, `name_taken` | Done | src/api/accounts.rs:20-31; accounts.rs::e18_invalid_request_validation_failed_and_name_taken | | +| AC-6.5 (server) | delete only when empty (expense, allocation, template); tombstone | Done | core_api/accounts.rs::e22_account_not_empty_for_expense_settlement_or_template | | +| AC-7.2 | fourteen seeded defaults | Done | migrations/0001, 0002; skeleton/migration.rs::a_fresh_database_holds_exactly_the_fourteen_defaults | | +| AC-7.3 | `sort_order` = max + 10 | Done | core_api/categories.rs::e23_creates_with_sort_order_max_plus_10 | | +| AC-7.6 (server) | `category_in_use`, `category_is_default`, soft delete | Done | core_api/categories.rs::e27_* | | +| AC-9.2 | 06:00 Europe/Stockholm, days 29-31 clamp | Done | core_api/generation.rs::occurrence_is_at_0600_europe_stockholm_in_summer_and_winter, ::days_29_30_31_clamp_to_the_last_day_including_february_2027 | | +| AC-9.3 / §9.5 | catch-up, idempotency, split resolution, pause and resume without backfill, clock jumps | Done | generation.rs::catch_up_…, ::an_existing_period_is_skipped_silently_by_the_unique_index, ::mode_default_resolves_the_account_ratio_at_generation_time, ::modes_ratio_full_and_exact_use_the_template_splits, ::paused_generates_nothing_and_resume_does_not_backfill, ::resume_before_start_period_keeps_the_start, ::end_period_stops_generation_and_next_occurrence_becomes_null; scheduler_daily/scheduler.rs::runs_at_start_then_at_06_00_and_survives_clock_jumps, ::next_06_00_is_local_time_across_both_dst_changes | | +| AC-9.4 (server) | template edits affect only later expenses | Done | generation.rs::archived_account_continues_and_edits_affect_only_later_expenses | | +| AC-9.5 | a deleted generated expense is not recreated | Done | generation.rs::a_deleted_generated_expense_is_not_recreated | | +| AC-9.6 | audit `source` recurring; push to both users | Done | sync_api/audit.rs::generation_writes_recurring_audit_with_server_now_and_no_device; push_fcm/fcm.rs::recipients_…_recurring_goes_to_both | | +| AC-9.7 (server) | `next_occurrence_date` computed by the server | Done | kvitt-domain/src/recurring.rs; generation.rs::end_period_stops_generation_and_next_occurrence_becomes_null | | +| AC-11.8 | E-33 equals `propose_allocation`, shared vectors | Done | kvitt-domain/tests/vectors.rs::every_vector_file_passes; core_api/proposal.rs::e33_matches_the_example, ::e33_honours_exclude_settlement_id_and_settlements | | +| AC-12.1 | audit for every accepted create, update, delete (app, scheduler, importer); append-only | Done | src/audit/mod.rs; sync_api/audit.rs::entries_are_append_only_create_update_delete; kvitt-import tests/import.rs::english_export_imports_expenses_settlements_and_audit | | +| AC-12.2 | entry fields: actor device null for scheduler and importer, client_at, server_at, diff §14.4.7 | Done | src/audit/diff.rs (with unit tests); sync_api/audit.rs; push_rules.rs::step11_newer_update_writes_the_diff_and_keeps_server_owned_fields | | +| AC-13.1 | push to the other user's live devices with a token | Done | src/push/sender.rs:87-94; push_fcm/fcm.rs::recipients_…; sync_api/e2e.rs::create_on_device_a_pull_on_device_b_row_audit_and_one_push | | +| AC-13.2 | recurring generation goes to both users | Done | push_fcm/fcm.rs::recipients_…_recurring_goes_to_both | | +| AC-13.3 | the importer sends no pushes | Done | server/crates/kvitt-import/src/db.rs:4 (no push queue in the crate) | true by construction; no explicit test | +| AC-13.4 | more than 3 notifiable changes give one `sync_batch` | Done | src/sync/push.rs:36,713-715; push_rules.rs::more_than_3_notifiable_changes_send_one_sync_batch_3_send_each | | +| AC-13.7 (server) | sends to `message.fid` | Done | src/push/fcm.rs:116-121; push_fcm/fcm.rs::payload_of_every_type_equals_the_contract_example | | +| AC-13.8 | no personal content in the payload | Done | push_fcm/fcm.rs::payload_of_every_type_equals_the_contract_example; core_api/logging.rs::logs_hold_no_personal_content | | +| §14.6 queue | bounded at 100, the oldest dropped | Done | src/push/queue.rs:12,62; push_fcm/fcm.rs::full_queue_drops_the_oldest | | +| §14.6 auth | RS256 JWT, token cached until expiry minus 5 min, 401 drops the token | Done | src/push/fcm.rs:268,302; fcm.rs::oauth_jwt_and_send_request_follow_the_http_v1_flow, ::access_token_is_cached_until_5_minutes_before_expiry | | +| §14.6 retries | 1 s, 5 s, 25 s on 429, 5xx, network | Done | src/push/sender.rs:26-28; fcm.rs::production_retry_delays_are_1_5_25_seconds, ::retries_429_and_5xx_…, ::network_errors_are_retried | | +| §14.6 unregistered | `UNREGISTERED` or 404 clears the token if unchanged | Done | src/push/fcm.rs:93,135-142,296; fcm.rs::unregistered_or_404_clears_the_devices_token | | +| §14.6 disabled | missing credentials: Noop, one warning | Done | fcm.rs::missing_or_unusable_credentials_disable_push | | +| §14.4.2 pull | cursor_ahead, params, order, paging, tombstones, epoch | Done | src/sync/pull.rs; sync_api/pull.rs (7 tests) | | +| §14.4.4 pre-checks | 0 or more than 100 is `batch_too_large`, 1 MiB, invalid envelope | Done | push_rules.rs::batch_of_101_is_batch_too_large_and_0_too_and_100_is_fine, ::body_over_1_mib_is_payload_too_large, ::malformed_body_or_envelope_is_invalid_request | | +| §14.4.4 steps 1-4 | replay, household, shape, clock_skew | Done | push_rules.rs::step1_…, ::duplicate_mutation_ids_…, ::step2_…, ::step3_…, ::step4_… | | +| §14.4.4 steps 5-8 | tombstone, delete wins, never-synced tombstone, validation and references | Done | push_rules.rs::step6_…, ::step7_… (2), ::step8_… (5) | | +| §14.4.4 steps 9-12 | insert, stale, diff update, empty diff, record mutation | Done | push_rules.rs::step9_…, ::step10_…, ::step11_… (2), ::step12_…; sync_api/conflicts.rs (5 tests) | | +| AC-15.6 (server) | 14 days offline with 200 changes syncs | Done | sync_api/offline.rs::fourteen_days_offline_200_changes_all_sync | the phone side is out of this part | +| §14.1 pruning | `sync_mutations` older than 90 days pruned at 04:00 | Done | src/tasks/scheduler.rs:106-109; scheduler.rs::pruning_deletes_rows_older_than_90_days_and_keeps_the_rest, ::pruning_runs_at_04_00_local | | +| §8.6 | 60/min per IP, 600/min per device, XFF from the trusted proxy only, body limits 1 MiB and 5 MiB | Done | src/router.rs:22-24,108-109; core_api/rate_limit.rs (5 tests); sync_api/serve.rs::two_client_addresses_get_separate_rate_limit_buckets_end_to_end | | +| AC-17.1 | `kvitt-import` CLI, one file per group into a named account | Done | server/crates/kvitt-import/src/cli.rs:87-114; tests/import.rs::cli_end_to_end_exit_codes, ::cli_help_and_usage_errors | | +| AC-17.2 | a rerun creates nothing (`import_key`) | Done | kvitt-import/src/db.rs:111,345; import.rs::rerun_creates_nothing_and_existing_account_is_reused | | +| AC-17.3 | fixed category table; unknown names map to `other` and are listed | Done | kvitt-import/data/splitwise_categories.csv; import.rs::category_table_matches_the_spec, english_export… lines 198-262 | | +| AC-17.4 | payments become settlements with one allocation | Done | import.rs lines 296-300 | | +| AC-17.5 | `--dry-run` rolls back, same counts and exit status | Done | import.rs::dry_run_writes_nothing_and_reports_the_same_counts | | +| AC-17.6 | audit `source` import, no pushes | Done | kvitt-import/src/db.rs:300; import.rs lines 335- | | +| AC-18.1 | nightly 03:30 snapshot and pictures archive, gzip, rclone to Drive with `--immutable` | Partial | infra/backup/Dockerfile:22-27, infra/backup/backup.sh:27-69, infra/pi/compose.yaml (backup service) | implemented; never run on the Pi (M2c-pi is owner-run, D-100); no automated test | +| AC-18.2 | restore drill documented in `docs/validation/restore-drill.md` | Missing | infra/RESTORE.md (88 lines, the procedure) | `docs/validation/` does not exist; the drill has not been run | +| AC-20.1 | `/health` unauthenticated, in under 100 ms on the Pi | Partial | skeleton/routes.rs::health_* | function Done; the 100 ms target on the Pi is not measured (not verifiable without the Pi) | +| AC-20.2 | the compose healthcheck of `server` uses it | Done | infra/pi/compose.yaml:65-66; infra/dev/compose.yaml:52-53 | | + + +### Part B: Android side, FR-2 to FR-12, §9.2/§9.8/§9.9/§9.11, screens S-1 to S-10 (main at ed1b130) + +Convention: Done = code present in the real `:data`/feature/`:app` path and a test (unit, screenshot or the M8 phone check) demonstrates it; Partial = code present but the §4 evidence rule is not met, or a sub-clause missing; Not verifiable without device = only an on-device behaviour (system UI) proves it. Test names are JUnit method names in the cited file. Paths are relative to `android/`. + +| id | requirement (one line) | status | evidence (file:line or test name) | note | +|---|---|---|---|---| +| AC-2.1 (phone) | Admin creates a partner invite from Settings; new one cancels pending | Done | feature/settings/.../invites/InvitesViewModel.kt:88 `invitePartner`; hub/SettingsHubViewModel.kt:47; StateHolderTest.invitesCreateAndRefuse | `household_full`, cancel-previous are server rules (fork A) | +| AC-2.2 (phone) | Admin creates a device invite for either user | Done | InvitesViewModel.kt:91 `inviteDevice(user)`; InvitesScreen.kt:198 | | +| AC-2.4 (phone) | Accept takes code, device name, display name; returns kind, user, household | Done | data/.../repo/SessionRepositoryImpl.kt; SessionRepositoryTest.pair_storesCredentials_writesTheHousehold_andStartsAFullSync, pair_reportsTheInviteKind_… | | +| AC-2.6 | `kvitt://invite` and `https://…:8443/i/` open S-1 prefilled; `from` names the admin; ignored while paired | Done | app/src/main/AndroidManifest.xml:31-47 (`autoVerify="false"`, scheme kvitt host invite, https host/port/pathPrefix /i/); app/.../link/AppLinks.kt; nav/KvittApp.kt:210-221 (prefill), :130-137 (S-1 only when unpaired); AppLinksTest.appLinkFillsServerAndCode, landingLinkFillsTheServerFromTheLink, linksWithTheAdminNameCarryIt_andOldLinksStillParse; InviteLinkTest | "Ignored while paired" holds because S-1 is not in the paired graph; the parsed invite stays in `AppViewModel.invite` (AppViewModel.kt:47-49) and would prefill S-1 if the phone later becomes unpaired. No test of the paired case. Chooser behaviour is device-only | +| AC-2.7 | Manual entry; URL prefilled in release; code normalised per §8.3 | Done | feature/onboarding/.../ServerAddress.kt:7; PairingViewModelTest.theCodeIsNormalisedWhileTyping, aFirstPairingSendsTheNormalisedCodeAndTheTrimmedName; ComponentLogicTest.inviteCodesAreNormalisedPerSpec83 | | +| AC-2.8 | Release https only; debug also http for localhost/10.0.2.2/private LAN, in `normaliseServerUrl` | Done | SessionRepositoryTest.serverUrls_releaseHttpsOnly_debugAlsoLocalHttp; ServerAddressTest.debugAcceptsHttpOnlyForLocalAndPrivateAddresses; PairingViewModelTest.releaseRefusesHttpBeforeCallingTheServerAndDebugAllowsTheLan | | +| AC-2.9 | Full sync after pairing, then ask POST_NOTIFICATIONS | Not verifiable without device | app/.../AppViewModel.kt:60-66 (waits for `lastSuccessAt != null && !running`); nav/KvittApp.kt:104-116 | No unit test of AppViewModel; the system prompt is device-only | +| AC-2.10 | Partner pairing goes to S-2; every other pairing to Home | Done | nav/KvittApp.kt:213-217; PairingViewModelTest.aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin, theAdminGoesHomeAndTheAgainVariantSendsNoName | | +| AC-2.11 | Admin sees pending invites (kind, created, expiry) and cancels each | Done | InvitesScreen.kt:223-263; InvitesViewModel.kt:118 `cancel`; DevicesInvitesAboutScreenshotTest | | +| AC-3.1 | Devices list: name, owner, paired, last seen, app version name else code, "This device" | Done | feature/settings/.../devices/DevicesScreen.kt; DevicesViewModel.kt:77; StateHolderTest.devicesLoadFailAndRevoke; DevicesInvitesAboutScreenshotTest | Revoked devices are filtered out of the list (DevicesViewModel.kt:77); the spec does not ask to show them | +| AC-3.2 (phone) | Admin revokes any but current; member own but current | Done | DevicesViewModel.kt:39-40 `canRevoke`; PureRulesTest.revokeRules | Server enforces too (fork A) | +| AC-3.3 | On 401 device_revoked: delete token, DB, cached pictures; show S-18 "Pair again" | Done | data/.../di/DataModule.kt:166-170 (clearAllTables, Coil memory and disk cache); auth/SessionManager.kt:35; SyncEngineTest.deviceRevoked_wipesTokenAndLocalData; CredentialStoreTest.revokedDeletesTokenAndKey_unrecognisedKeepsTheUser; KvittApp.kt:133, :226-231 | | +| AC-4.1 | Display name and optional picture | Done | model User(displayName, pictureVersion) | | +| AC-4.2 | Edit own display name in Settings, online only | Done | feature/settings/.../profile/ProfileViewModel.kt; StateHolderTest.profileNameRulesAndSave, profileOfflineDisablesEverything | | +| AC-4.3 | Picture from the Android photo picker or removed; online only | Not verifiable without device | profile/ProfileScreen.kt:59-74 and onboarding ProfileStepScreen.kt:96-117 (`PickVisualMedia`); StateHolderTest.profileRefusedNameIsInlineAndPictureErrorsAreSnackbars; OtherViewModelsTest.offlineThePictureCannotChange | Picker itself is system UI | +| AC-4.5 | Initials on avatar-1/avatar-2, slot a stable function of the user id | Done | core/design/.../component/People.kt:55; ComponentLogicTest.initialsAreTheFirstLettersOfTheFirstTwoWords, avatarSlotIsTheLowestIdFirstOnBothPhones | | +| AC-4.6 | Pictures cached on disk (Coil) keyed by user id and picture_version | Not verifiable without device | data/.../repo/Pictures.kt:26-38 (disk cache, URL carries user id and `v=`) | Offline display not tested | +| AC-5.1 | Without partner, Home is "Waiting for your partner" with "Invite partner"; no creation offered | Done | nav/KvittApp.kt:236-240; feature/onboarding WaitingScreen.kt; OtherViewModelsTest.waitingShowsThePendingInviteExpiryOnlyOnline; OnboardingScreenshotTest | Gate in `:app` not unit-tested | +| AC-6.1 | Create account online, starts 50/50, percent with two decimals, other = 100 − first | Done | feature/account AccountEditorViewModel.kt; AccountViewModelTest.newAccountStartsAtFiftyFiftyAndNeedsAName, percentsAcceptTwoDecimalsAndTheOtherFollows, createSavesBasisPointsAndReportsTheNewId | | +| AC-6.2 (phone) | Duplicate name shows `name_taken` | Done | AccountViewModelTest.nameTakenIsShownUnderTheName | Rule is server-side | +| AC-6.3 | Rename or change ratio online; ratio change affects only later expenses | Done | AccountViewModelTest.editStartsFromTheAccount; default ratio copied into the expense at save (ExpenseRepositoryImpl.kt save; RepositoriesTest.preview_usesTheAccountDefault) | | +| AC-6.4 | Archive/unarchive online; hidden from pickers; collapsed on Home; still in balance, analytics, settle | Done | data ExpenseRepositoryImpl.kt:57 (pickers active only); HouseholdRepositories.kt:100-101; feature/settle SettleViewModel.kt:81-82 (archived addable); AccountViewModelTest.archiveNavigatesBackWithTheName, unarchiveStaysWithASnackbar, archivedAccountHidesTheFab; HomeViewModelTest.archivedSectionToggles | Undo snackbar wired: KvittApp.kt:289-293 | +| AC-6.5 (phone) | Delete only when no live expense, allocation or template references it | Done | data HouseholdRepositories.kt:151-154 `accountDeletable`; AccountViewModelTest.emptyAccountMayBeDeletedOnlyOnline, deleteOfANonEmptyAccountIsRefused, deleteAsksThenDeletes | | +| AC-6.6 | Accounts by created_at ascending | Done | data Snapshot.kt:90 | | +| AC-6.7 | Account create/edit disabled offline with "Needs connection" | Done | AccountEditorScreen.kt:171; AccountViewModelTest.offlineCannotSave, offlineActionSaysNeedsConnection | | +| AC-7.1 | One global editable list: name, icon token, colour token | Done | model Category; ContractTest.categoryIconsAreTheThirtyThreeTokensOfSpec9_7InOrder | | +| AC-7.4 | Rename, re-icon, recolour, reorder; "Reset name" for defaults | Done | StateHolderTest.categoryResetNameSavesNull, categoryReorderFailureRestoresTheOrder; CategoriesScreenshotTest | | +| AC-7.5 | Archived categories not offered for new expenses/templates, kept on existing ones and analytics | Done | data ExpenseRepositoryImpl.kt:58; ComponentLogicTest.quickRowSkipsArchived; StateHolderTest.archiveReturnsWithASnackbarForTheList | | +| AC-7.6 (phone) | Delete only if unused and not default; reason shown | Done | EditorCategoriesTest.deletable_falseForDefaults_unknownAndDeleted, deletable_followsExpensesAndTemplates_archivedStillCounts; StateHolderTest.categoryDeleteAffordanceFollowsDeletable | | +| AC-7.7 | No uniqueness rule for category names | Done | no duplicate check in feature/settings categories (CategoryEditorViewModel.kt) | Absence of a rule | +| AC-8.1 | Expense fields as listed | Done | model ExpenseDraft / ExpenseDetailState; data db/Entities.kt | | +| AC-8.2 | Create/edit/delete any expense online or offline; UUIDv7 client id | Done | RepositoriesTest.expenseSave_writesAPendingRowWithAMutationIdAndTimestamps; ExpenseEditorViewModelTest.offlineSave_isAllowed_andReportsOffline; data util/Ids.kt | | +| AC-8.3 | Payer either user, defaults to current user | Done | ExpenseEditorViewModelTest.newExpense_appliesTheDefaults | | +| AC-8.4 | Any date within limits, default today in phone zone | Done | ExpenseEditorViewModelTest.dateTooFarAhead_comesBackAsValidationFailed, newExpense_appliesTheDefaults | | +| AC-8.5 | Split modes default/ratio/exact/full, shares per §9.1 | Done | domain Split.kt; VectorTest (compute_split.json); ExpenseEditorViewModelTest.ratio_…, full_defaultsToThePersonWhoDidNotPay | | +| AC-8.6 | Editor defaults: last-used account else oldest active; category suggestion then last-used else `other`; none if `other` archived | Done | data ExpenseRepositoryImpl.kt:54-74; RepositoriesTest.editorDefaults_lastUsedAccountAndCategory_elseOldestActiveAndOther; ExpenseEditorViewModelTest.noDefaultCategory_showsNoCategory_andBlocksSaving | | +| AC-8.7 | Exact mode: other share read-only; typed > amount blocks saving | Done | ExpenseEditorViewModelTest.exactShareLargerThanTheAmount_blocksSaving | | +| AC-8.8 | Account change in default mode re-copies ratio; other modes kept | Done | ExpenseEditorViewModelTest.accountChange_inDefaultMode_recopiesTheRatio | | +| AC-8.9 | Delete asks for confirmation, soft delete, gone from lists/balances/analytics | Done | ExpenseDetailViewModelTest.delete_asksThenDeletes; RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones, analytics_includePending_excludeDeleted; SyncEngineTest.delete_pushesTheFullEntityWithDeletedAt_andKeepsTheTombstone | | +| AC-8.10 | Rows: icon, colour, description, date, amount, effect line; date desc then created_at desc | Done | core/design component/Ledger.kt, Money.kt:137-148; data HouseholdRepositories.kt:157-166 (ledgerOrder); AccountScreenshotTest | | +| AC-8.11 | Generated expense is ordinary; detail shows "Recurring" with template name | Done | ExpenseDetailScreen.kt:217, :238-239; ExpenseDetailViewModelTest.recurring_namesTheTemplate | | +| §9.2 | Effect line Lent / Borrowed / Not involved / Paid for yourself | Partial | data Snapshot.kt:124-132 `effectFor`; core/design Money.kt:137-148 | Logic is correct by reading, but no `:data` unit test covers the four cases (screenshots use the fakes) | +| CS-1 | Suggestion only for a new expense, never on edit | Done | ExpenseEditorViewModel.kt:217-218, :493; ExpenseEditorViewModelTest.editMode_loadsTheExpense_keepsTheCategory_andDeletes | | +| CS-2 | Picking by hand stops suggestions for the session | Done | ExpenseEditorViewModel.kt:339; ExpenseEditorViewModelTest.suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault | | +| CS-3 | Recompute on description and account change, local only | Done | ExpenseEditorViewModel.kt:326, :347, :491-502 | 150 ms debounce (not in spec, harmless) | +| CS-4 | Normalisation (NFC, lowercase, delete ' ’ :, non-letter/digit/& to space) | Done | domain CategorySuggestion.kt:49; CategoryKeywordsTest.platformUnicodeText_nfc_letters_nd_digits_supplementary; VectorTest (suggest_category.json) | | +| CS-5 | Order: exact history, first-token history, keywords, default; ties most recent then id | Done | domain CategorySuggestion.kt:74-122; CategorySuggestionTest.history_countsEveryAccountAndBothUsers_mostOftenWins_deletedLeftOut, history_tie_goesToTheMostRecentlyUsed, fallback_isTheCategoryLastUsedInTheAccount_elseOther | | +| CS-6 | Keyword table token/prefix/phrase, longest wins, archived skipped | Done | CategorySuggestion.kt:134-143; CategorySuggestionTest.keywords_applyWithoutHistory_andNeedTheKeysCategory, archivedCategories_areSkipped_inHistoryAndKeywords; data CategoryKeywordsTest.bundledTableIsByteIdenticalToTheContractFile | | +| CS-7 | No marker; clearing returns to step-4 default | Done | ExpenseEditorViewModelTest.suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault; ExpenseScreenshotTest | | +| AC-10.1 | Home global balance wording from the viewer's side | Done | HomeViewModelTest.householdShowsBalanceAccountsAndFab, settledHasNoSettleButton; HomeScreenshotTest | | +| AC-10.2 | Active accounts with balance; archived collapsed | Done | HouseholdRepositories.kt:100-101; HomeViewModelTest.archivedSectionToggles | | +| AC-10.3 | Account screen: balance and expenses and settlements interleaved by date | Done | HouseholdRepositories.kt:157-166; RepositoriesTest.accountLedger_settlementRowCarriesTheAllocationToThatAccount; AccountViewModelTest.loadedAccountShowsBalanceMonthsAndFab | | +| AC-10.4 | Balances on the phone, include pending, exclude deleted, update after local write | Done | RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones; domain Balances.kt; VectorTest (compute_balances.json) | | +| AC-10.5 | Global = sum of per-account balances, archived included | Done | RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones (assert "AC-10.5") | | +| AC-11.1 | Settlement: amount, date, optional note, ≥1 allocations | Done | model SettleRequest; SettleViewModelTest.invalidNoteIsReportedAndClearedOnEdit | | +| AC-11.2 | Allocations sum to amount, each positive, account at most once | Done | domain Allocation.kt; VectorTest (validate_settlement.json); SettleViewModelTest.removeAndAddAccountsUpdateTheRows | | +| AC-11.3 | Global settle-up defaults, proposal rows, "Add account" (archived too), confirm only when sums match, live remaining | Done | data SettlementRepositoryImpl.kt:57-61; SettleViewModel.kt:81-84; RepositoriesTest.settleStart_theUserWhoOwesPays_amountIsTheBalance; SettleViewModelTest.globalDefaultsAreTheProposal, zeroBalanceGivesEmptyAmountAndNoRows | | +| AC-11.4 | Per-account settle-up: one allocation, defaults to account balance and debtor | Done | SettlementRepositoryImpl.kt:59; SettleViewModelTest.perAccountHasOneFixedRowThatFollowsTheAmount | | +| AC-11.5 | Over-settle allowed, warned per row, confirm dialog repeats | Done | SettleViewModel.kt:102, :194; SettleViewModelTest.overSettlingWarnsAndConfirmOpensTheDialog, saveWithoutWarningsIsDirect | | +| AC-11.6 | Create/edit/delete offline; editing excludes itself; saved rows count as edited; "Suggest again" | Done | SettleViewModel.kt:157, :184; SettleViewModelTest.editingStartsFromTheSavedSettlement, offlineWorksUnchanged; RepositoriesTest.settleState_listsTheAccountsTheSettlementLeavesUntouched | | +| AC-11.7 | Settlement row " paid X", account count when > 1; allocation in S-5 | Done | core/design Ledger.kt:115-128; data Snapshot.kt:141; RepositoriesTest.accountLedger_settlementRowCarriesTheAllocationToThatAccount | | +| AC-12.3 (phone) | Audit entries reach the phone through pull | Done | SyncEngineTest.pull_auditEntriesAreInsertOrIgnore | | +| AC-12.4 | Detail timeline, newest last, field names in UI language, ids resolved to names | Done | data repo/Timeline.kt:31-69; core/design component/Timeline.kt:50-64, :76-93; RepositoriesTest.expenseDetail_timelineResolvesNames_andEndsWithThePendingLine | `splits`/`allocations` rendered as "name amount; …" (accepted in §14.2.4 v0.5) | +| AC-12.5 | Pending local change ends the timeline with "Your change is waiting to sync" | Done | Timeline.kt (data):38-44; ExpenseDetailViewModelTest.loaded_withThePendingLineLast; SettlementDetailViewModelTest.pendingChangeEndsTheTimeline | | +| §9.8 money | `1 234,50 kr` / `SEK 1,234.50`, no sign | Done | core/design format/MoneyFormatter.kt; MoneyFormatterTest.swedishUsesNoBreakSpacesAndCommaDecimal, otherLocalesPutSekFirstWithCommaThousands, neverShowsASign | | +| §9.8 dates | List, detail, timeline, axis, day ranges | Done | DateFormatterTest.listDatesWithoutYearInTheCurrentYear, listDatesWithYearInOtherYears, detailDates, timesAre24HourInTheLocalZone, monthNamesAndLongDayRanges | | +| §9.8 relative | "just now", "N min ago", "N h ago", else date and time | Done | DateFormatterTest.relativeLastSynced | | +| §9.8 file sizes | SI MB one decimal half up | Done | MoneyFormatterTest.megabytesHaveOneDecimal | | +| §9.9 | Online-only gate: validated network and 30 s after a network error; REST failure changes nothing locally | Done | data connectivity/Connectivity.kt; RepositoriesTest.onlineOnly_withoutValidatedNetwork_isOffline_andSendsNothing, onlineOnly_withinThirtySecondsOfANetworkError_isOffline, rest_networkFailure_changesNothingLocally; KvittApiTest.networkFailureIsNetworkError_andOpensTheThirtySecondWindow | Per-screen gating of S-13 to S-22 is fork C's | +| S-1 Pair | Pair with URL, code, name; progress; errors invite_invalid, network, rate_limited | Done | PairingViewModelTest.errorsMapToTheirFields, whilePairingTheButtonIsBusyAndEditingClearsTheFieldError, offlinePairingGivesTheNetworkErrorUnderTheExpandedServer; OnboardingScreenshotTest | | +| S-1 Paste code | Paste fills code (and server from a pasted link) | Done | PairingViewModelTest.pasteFillsCodeAndServerOrSaysTheCodeHasTwelveCharacters | | +| S-2 Continue | Name prefilled, blank shows error, "Add picture", "You are joining " | Done | KvittApp.kt:223-225; OtherViewModelsTest.theProfileStepPrefillsTheNameAndNamesTheOwner, continueSavesAChangedNameAndStaysOnFailure, anUploadDisablesContinueUntilItEndsAndFailuresGiveTheirReason | | +| S-3 Invite partner | Goes to S-21 | Done | KvittApp.kt:239 | | +| S-3 Settings | Bottom bar to Settings | Done | WaitingScreen.kt:100 (`KvittNavigationBar`), KvittApp.kt:239 `onNavigate = shell::top` | | +| S-3 must show | Expiry of pending invite; error snackbar with retry | Done | WaitingScreen.kt:91-93, :150-168; OtherViewModelsTest.aFailedInviteListIsAnErrorAndComingOnlineLoadsAgain | | +| S-4 Add expense (FAB) | FAB opens S-7 | Done | KvittApp.kt:242; HomeViewModelTest.emptyAndOnlyArchivedHideTheFab | | +| S-4 Settle up | Button when non-zero opens S-9 | Done | KvittApp.kt:243; HomeViewModel.kt:53 | | +| S-4 open account | Row opens S-5 | Done | KvittApp.kt:244 | | +| S-4 New account | Opens S-6, then S-5 of the new account | Done | KvittApp.kt:245, :301-304 | | +| S-4 must show | Sync line (tap S-16), update banner with Later, expenses this month, empty state | Done | KvittApp.kt:246; HomeViewModel.kt:96-124; HouseholdRepositories.kt:93; HomeViewModelTest.offlineAndPendingAreReported, laterHidesTheBanner, firstUpdateOpensTheDialogAndContinueInstalls; HomeScreenshotTest | | +| S-5 Add expense | FAB (not on archived) opens S-7 in this account | Done | KvittApp.kt:286; AccountViewModelTest.archivedAccountHidesTheFab; ExpenseEditorViewModelTest.openedFromAnAccount_startsInThatAccount | | +| S-5 Settle this account | Opens S-9 per-account | Done | KvittApp.kt:287 | | +| S-5 Edit account | Opens S-6 | Done | KvittApp.kt:288 | | +| S-5 Archive / Unarchive | With undo snackbar on Home after archive | Done | KvittApp.kt:289-293; AppViewModel.kt:73-75; AccountViewModelTest.archiveNavigatesBackWithTheName, unarchiveStaysWithASnackbar | | +| S-5 Delete (only if allowed) | Shown only when deletable, confirmation | Done | AccountDetailScreen.kt:160; KvittApp.kt:294; AccountViewModelTest.emptyAccountMayBeDeletedOnlyOnline, deleteAsksThenDeletes | | +| S-6 Save | Name, split control, preview "Of 100,00 kr …", needs connection | Done | AccountEditorScreen.kt:171, :273-282; KvittApp.kt:301-304; AccountViewModelTest.createSavesBasisPointsAndReportsTheNewId | | +| S-6 Cancel | Discard dialog title only | Done | AccountEditorScreen.kt:94, :132-137; AccountViewModelTest.closingWithChangesAsksFirst | | +| S-7 Save | Saves and shows "Utgiften sparades" on the screen returned to | Done | KvittApp.kt:311-314; ExpenseEditorViewModelTest.tenSecondPath_amountDescriptionSuggestionSave, editSave_reportsAnEdit | | +| S-7 Cancel | Discard dialog only with changes | Done | KvittApp.kt:310; ExpenseEditorViewModelTest.close_asksFirstOnlyWithChanges_andBackHidesTheKeypadFirst | | +| S-7 Delete (edit mode) | Delete, closes detail too, snackbar | Done | KvittApp.kt:315-319; ExpenseEditorViewModelTest.editMode_loadsTheExpense_keepsTheCategory_andDeletes | | +| S-7 must show | Four fixed rows, quick row, notes collapsed, live preview | Done | EditorOptionRowsLayoutTest.rowsStayPutWhenValuesChange; ExpenseEditorViewModelTest.quickRow_…; ExpenseEditorScreen.kt:265-266; ExpenseScreenshotTest | | +| S-8 Edit | Opens S-7 in edit mode | Done | ExpenseDetailScreen.kt:130-131; KvittApp.kt:325 | | +| S-8 Delete | Confirmation, back, snackbar | Done | ExpenseDetailScreen.kt:132; KvittApp.kt:326-329; ExpenseDetailViewModelTest.delete_asksThenDeletes | | +| S-8 must show | Deleted state; missing state with "Synka nu" online only | Done | ExpenseDetailScreen.kt:143-170, :244-257; ExpenseDetailViewModelTest.missing_isNullAfterLoading, deleted_staysVisibleAsDeleted | | +| S-9 Confirm | Saves; with warnings via dialog; snackbar on return | Done | KvittApp.kt:336-339; SettleViewModelTest.overSettlingWarnsAndConfirmOpensTheDialog, saveWithoutWarningsIsDirect | | +| S-9 Cancel | Discard dialog only with changes | Done | KvittApp.kt:335; SettleViewModelTest.closeAsksOnlyWithChanges | | +| S-9 must show | Direction swap, remaining, add account, date, note, untouched info line | Done | SettleViewModel.kt:163, :175; SettleViewModelTest.swapReproposesForTheOtherPayer; SettleScreenshotTest | | +| S-10 Edit | Opens S-9 editing | Done | KvittApp.kt:345 | | +| S-10 Delete | Confirmation, back, snackbar | Done | SettlementDetailScreen.kt:110, :139-147; KvittApp.kt:346-349; SettlementDetailViewModelTest.deleteAsksThenDeletes | | +| S-10 must show | Missing state with "Synka nu" online only; deleted state | Done | SettlementDetailViewModel.kt:46-49, :97; SettlementDetailViewModelTest.missingOfflineCarriesTheConnectivityForSyncNow, deletedHasTheDeletionLineAndNoActions | | + + +### Part C: Android recurring, push, analytics, sync, update, localisation; S-11 to S-22; UI contract; TODO scan + +Audited at `ed1b130`, read-only. Paths are relative to `android/` unless they start with `server/`. "fork A" means the server-side rows are in part A. + +#### (1) Table + +| id | requirement (one line) | status | evidence (file:line or test name) | note | +|---|---|---|---|---| +| AC-9.1 | Create, edit, pause, resume and delete a template (online), with every field | Done | `data/.../repo/SettingsRepositories.kt:135-185` (E-28/E-29/E-30 via `OnlineGate`); `feature/settings/.../recurring/RecurringEditorViewModel.kt:187,221,250`; StateHolderTest `newTemplateDefaultsAndSave`, `editTemplateLoadsSplitAndMarksServerErrors` | Pause and resume are the editor's pause switch, saved with E-29 | +| AC-9.4 | Editing a template changes only expenses generated later | Done | server test `generation.rs::archived_account_continues_and_edits_affect_only_later_expenses` | Server-side rule; the phone only sends E-29 | +| AC-9.7 | List shows description, amount, account, day of month, paused state, next occurrence (server `next_occurrence_date`) | Done | `feature/settings/.../recurring/RecurringListScreen.kt:119-153`; `SettingsRepositories.kt:245` (`nextOccurrence = row.nextOccurrenceDate`); RecurringScreenshotTest | | +| AC-9.8 | Default `start_period` (current month if the day is today or later, else next); new day = today; backfill up to 12 months | Done | `feature/settings/.../recurring/Periods.kt:24-33`; `RecurringForm.kt:136,200`; PureRulesTest `periods`, `templateValidation` | | +| AC-13.5 | Notification only after its sync; text from local data in the phone's language; generic text when the sync fails within 20 s | Done | `app/.../push/PushWork.kt:25-30`; `data/.../sync/SyncTriggers.kt:70-75` (20 000 ms); `app/.../push/Notifications.kt:50-57`; NotificationTextsTest (`failedSyncGivesTheGenericTextWithoutContent`, `missingEntityGivesTheGenericText`); SyncTriggersTest `pushWaitGivesUpAfterItsTimeout` | Real delivery was seen in the M8 phone test (HANDOFF) | +| AC-13.6 | A tap opens S-8 or S-10; Home for deletions, `sync_batch` and the generic text | Done | `app/.../push/NotificationTexts.kt` (targets); `nav/KvittApp.kt:136-144`; NotificationTextsTest `expenseDeletedOpensHome`, `settlementDeletedOpensHome`, `syncBatchCountsAndOpensHome`; AppLinksTest | | +| AC-13.7 | Register by FID when paired and at each start; send every FID with E-17; unregister on revoke | Done | `app/.../push/PushHandler.kt:37,52-62,68-76`; `adapter/fcm/.../KvittMessagingService.kt:22-23` | The M8 phone test confirmed `fcm_token` was registered. The FID is sent only while PAIRED, and is not retried after a network failure until Firebase calls `onRegistered` again | +| §14.6-1 | Phone step 1: `syncForPush`, waiting up to 20 s, in expedited work | Done | `PushWork.kt:40-54` (`setExpedited`); `SyncTriggers.kt:70-75` | | +| §14.6-2 | Channel `activity` ("Activity"/"Aktivitet", importance default); id = hash of `entity_id`; `sync_batch` uses id 1 | Done | `Notifications.kt:27-35`; `KvittApplication.kt:34`; `NotificationTexts.kt` `NotificationIds` (SYNC_BATCH = 1, `entityId.hashCode()`); NotificationTextsTest `updateReplacesCreate`, `syncBatchIsFixed` | | +| §14.6-3 | Tap targets as in AC-13.6 | Done | see AC-13.6 | | +| §14.6-4 | An unknown `type` or a `v` other than 1 syncs only, with no notification | Done | `adapter/fcm/.../PushMapping.kt:19-21`; `PushHandler.kt:32-35`; PushMappingTest `otherPayloadVersionsAreSyncOnly`, `unknownTypeIsKeptForTheAppToIgnore`; NotificationTextsTest `unknownTypeShowsNothing` | | +| AC-14.1 | Analytics are computed on the phone from Room, offline, with pending changes | Done | `data/.../repo/AnalyticsRepositoryImpl.kt:33-55`; RepositoriesTest `analytics_includePending_excludeDeleted`; AnalyticsViewModelTest `offlineWorksAndCountsPendingChanges` | | +| AC-14.2 | Range presets and custom; "last N months" is whole months; default `last_6_months` | Done | `domain/.../Analytics.kt:82-104`; vectors `contracts/test-vectors/resolve_range.json` (VectorTest); ResolveRangeTest; AnalyticsRulesTest `defaultsAreThoseOfAc14` | | +| AC-14.3 | Granularity month or ISO week (Monday start); default month | Done | `Analytics.kt:36-47,108-113`; spend_series vectors | | +| AC-14.4 | Measure household or my share; default household | Done | `Analytics.kt:53-56`; `AnalyticsRepositoryImpl.kt:95-96` | | +| AC-14.5 | Group by category or account; default category | Done | `Analytics.kt:57-60`; AnalyticsRulesTest `defaultsAreThoseOfAc14` | | +| AC-14.6 | Chart 1 stacked bars; chart 2 by category; chart 3 by account (sorted descending, with amount and percent); total above; settlements never counted | Done | `feature/analytics/.../AnalyticsScreen.kt:172,188-191,217-225`; `AnalyticsRepositoryImpl.kt:43-44` (sorted descending); `Analytics.kt` reads expenses only | | +| AC-14.7 | Every bucket appears, empty ones as zero; edge buckets clipped to the range | Done | `Analytics.kt:35-48,65-69`; spend_series vectors (13 cases) | | +| AC-14.8 | Tapping a bar or a row opens the expenses behind it | Done | `AnalyticsScreen.kt:111`; `KvittApp.kt` `Routes.DRILL_DOWN`; `AnalyticsRepositoryImpl.kt:57-86`; DrillDownViewModelTest | | +| AC-14.9 | Deleted expenses excluded; archived accounts and categories included | Done | `AnalyticsRepositoryImpl.kt:91-93` (`liveExpenses`); RepositoriesTest `analytics_includePending_excludeDeleted` | | +| AC-14.10 | Percent rounded half-up to one decimal | Done | `core/design/.../chart/Charts.kt:255-267` (`MoneyFormatter.percent`); MoneyFormatterTest `percentIsHalfUpToOneDecimal` | | +| AC-15.1 | Full local copy of users, accounts, categories, templates, expenses, settlements, audit | Done | `data/.../db/Entities.kt`, `KvittDatabase.kt`; SyncEngineTest `pull_*` | | +| AC-15.2 | Expenses and settlements work offline; other actions are disabled offline with "Needs connection" | Done | `data/.../connectivity/Connectivity.kt` (`OnlineGate`); RepositoriesTest `onlineOnly_withoutValidatedNetwork_isOffline_andSendsNothing`, `onlineOnly_withinThirtySecondsOfANetworkError_isOffline`; `NeedsConnectionHint` in CategoryListScreen, SyncStatusScreen; StateHolderTest `profileOfflineDisablesEverything` | | +| AC-15.3 | Sync on start, on foreground, 2 s after a write, every 15 min (network constraint), and on FCM (expedited); one at a time; names `kvitt-sync` APPEND_OR_REPLACE and `kvitt-sync-periodic` KEEP | Done | `SyncTriggers.kt:49-64,90-115`; `app/.../AppLifecycle.kt:37-50`; `SyncEngine.kt:55` (Mutex); SyncTriggersTest (4 tests), SyncEngineTest `oneCycleAtATime` | The WorkManager wiring itself is not unit-tested (a fake `SyncWork` is used) | +| AC-15.4 | Home shows "Last synced" and the pending count; sync status screen | Done | `core/design/.../component/Sync.kt` (`SyncStatusPill`); `feature/home/.../SyncStatusScreen.kt`; HomeScreenshotTest | | +| AC-15.5 | LWW per row, deletes final, rejected changes shown | Done | `SyncEngine.kt` (applyResults); SyncEngineTest `push_rejectedStale_*`, `push_rejectedWithoutCurrent_*`; RepositoriesTest `syncStatus_listsPendingRowsAndRejections_andDismisses` | | +| AC-15.6 | 14 days offline with 200 changes syncs everything (scripted clock) | Done | `data/src/test/.../sync/OfflineFourteenDaysTest.kt:44` `ac15_6_fourteenDaysOffline_twoHundredChanges_allSyncOnReconnect` | | +| §14.4.5-1 | Pending rows by `local_updated_at`, up to 100, `mutation_id = pending_mutation_id` | Done | `SyncEngine.kt:163-175,390`; SyncEngineTest `push_batchesOf100_oldestLocalChangeFirst_withExactlyTheClientWritableFields`, `push_mutationIdIsThePendingMutationId` | | +| §14.4.5-2 | Network error or 5xx: retry with backoff; 401 revoked/unauthorized; 429 retry; 426 stop; whole-request refusal goes on to pull and records `last_error_code` | Done | `SyncEngine.kt:125-160`; SyncEngineTest `push_networkError_*`, `push_serverError_asksForRetry`, `deviceRevoked_wipesTokenAndLocalData`, `unauthorized_keepsData_*`, `protocolUnsupported_stopsSyncingUntilUpdated` | No test covers the 429 path or the D-040 whole-request refusal (400/413/409/422) | +| §14.4.5-3 | Apply accepted results, rejections with equal content, other rejections, and `clock_skew` with the D-094 re-stamp | Done | SyncEngineTest `push_accepted_*` (2), `push_rejectedWithEqualContent_isTreatedAsAccepted`, `push_rejected_whileEditedAgain_*`, `push_clockSkew_*`, `push_afterClockSkew_restampsRowsStampedAhead_*`, `push_withoutAClockSkewFlag_neverRestamps` | | +| §14.4.8 | Epoch change and `cursor_ahead` lead to recovery; on 401 Unrecognised the data is kept; a re-pair by the same user keeps the data and re-pushes, another user wipes | Done | `SyncEngine.kt:314`; `data/.../repo/SessionRepositoryImpl.kt:66-72`; SyncEngineTest `epochChange_*`, `cursorAhead_recoversLikeANewEpoch`; SessionRepositoryTest | | +| AC-16.1 | Server hosts the latest APK and metadata | Done | `server/crates/kvitt-server/src/api/apk.rs`; server test `core_api/apk.rs` | Server side, see fork A | +| AC-16.2 | Release builds check E-5 on cold start and at most every 6 h while resumed; banner with version name | Done | `AppLifecycle.kt:44-47`; `data/.../update/Updater.kt:84-113,166`; UpdaterTest `checksAtMostEverySixHours`, `newerVersionIsAvailable_sameIsNone`; `feature/home/.../HomeScreen.kt:173,240` | | +| AC-16.3 | Download to cache, verify size and SHA-256, install via PackageInstaller; `NeedsInstallPermission` emitted once | Done | `Updater.kt:39-57,117-144,177-196`; UpdaterTest `verifiedDownloadIsInstalled`, `theInstallPermissionIsExplainedOnceBeforeTheFirstInstall`; `HomeScreen.kt:276-287` | The installer is behind a fake in tests | +| AC-16.3-device | The real PackageInstaller session and the system "Install unknown apps" prompt | Not verifiable without device | `Updater.kt:177-209` | Needs a signed release APK published on a server (M10) | +| AC-16.4 | A verification failure deletes the file and shows "Download failed, try again" | Done | `Updater.kt:137-141`; UpdaterTest `shaMismatchDeletesTheFile_andFails`, `sizeMismatchDeletesTheFile_andFails`; golden S-17 failed | | +| AC-16.5 | Debug builds never check | Done | `Updater.kt:85`; `AppLifecycle.kt:46`; UpdaterTest `debugBuildsNeverCheck` | | +| AC-16.6 | "Check for update" gives UpToDate, Failed or Available, with copy; debug gives UpToDate without a request | Done | `feature/settings/.../about/AboutViewModel.kt:43-59`; UpdaterTest `checkResult_tellsUpToDateFromAFailure` | In debug builds the button is hidden (`MainActivity.kt:35` `updateCheckAvailable = !BuildConfig.DEBUG`) rather than reporting UpToDate. This is consistent in effect | +| AC-19.1 | Every string is a resource in en and sv; no literal in feature code; the gate fails on a missing key | Done | `build-logic/.../AndroidCommon.kt:14-16` (`MissingTranslation` as error, `abortOnError`); StringsCatalogueTest; OnboardingStringsTest | Nothing in the gate stops a Kotlin string literal in a feature. A grep for `Text("`, `text = "` and `contentDescription = "` in `feature/*/src/main` found none | +| AC-19.2 | Money and dates formatted per §9.8 | Done | MoneyFormatterTest, DateFormatterTest (`core/design/src/test/...`) | | +| AC-19.3 | Default category names and user-visible enums exist in both languages | Done | StringsCatalogueTest `theFourteenDefaultCategoryNamesAndTheNotificationCopyArePresent`; lint `MissingTranslation` | | +| S-11 | Change filters (range, granularity, measure, group by, custom) | Done | `AnalyticsViewModel.kt`; AnalyticsViewModelTest `togglesReQueryAtOnce`, `customOpensThePickerAndAppliesOnlyWithBothDates` | | +| S-11 | Tap a bar or a row to open S-12 | Done | `AnalyticsScreen.kt:111`; `KvittApp.kt` ANALYTICS to DRILL_DOWN | | +| S-11 must-show | Total card, legend, empty state, skeleton loading, no error state | Done | `AnalyticsScreen.kt:172-225`; AnalyticsViewModelTest `firstSyncIsLoading`; AnalyticsRulesTest `loadingOnlyBeforeTheFirstSync` | | +| S-12 | Open expense | Done | `DrillDownScreen.kt:61-68`; `KvittApp.kt` DRILL_DOWN `onOpenExpense` to Routes.expense | | +| S-13 | Navigate (Profile, Categories, Recurring, Devices, Invite or Pair (admin), Sync status, About) | Done | `feature/settings/.../hub/SettingsHubScreen.kt:98-118`; `SettingsHubViewModel.kt:45-49`; `KvittApp.kt` SETTINGS block; StateHolderTest `hubCountsAndRows` | | +| S-14 | Save (name), change or remove the picture (photo picker), offline state | Done | `profile/ProfileScreen.kt:59-75,117`; `SettingsRepositories.kt:77-83`; StateHolderTest `profileNameRulesAndSave`, `profileOfflineDisablesEverything` | | +| S-15 | Add | Done | `KvittApp.kt` CATEGORIES `onNewCategory`; StateHolderTest `newCategoryNeedsAName` | | +| S-15 | Edit (name with "Reset name", 33 icons, 14 swatches) | Done | `categories/CategoryEditorScreen.kt`; StateHolderTest `categoryResetNameSavesNull`; `SettingsRepositories.kt:105-106` | | +| S-15 | Reorder (drag) | Done | `categories/CategoryListScreen.kt:98-101,164-179` (long press and handle drag, plus move up/down); `SettingsRepositories.kt:108-119` (one PATCH per changed `sort_order`); StateHolderTest `categoryReorderFailureRestoresTheOrder` | No `:data` test covers `CategoryRepositoryImpl.reorder` | +| S-15 | Archive | Done | `CategoryEditorViewModel.kt`; StateHolderTest `archiveReturnsWithASnackbarForTheList` | | +| S-15 | Delete, with the reason when not allowed | Done | `CategoryEditorViewModel.kt:29-42`; StateHolderTest `categoryDeleteAffordanceFollowsDeletable` | | +| S-16 | Sync now (online only) | Done | `feature/home/.../SyncStatusViewModel.kt:35-36,51-52`; `SettingsRepositories.kt:305-307` | | +| S-16 | Open a rejected change (read-only) | Partial | `SyncStatusScreen.kt` RejectionCard (around line 274): opens S-8/S-10 for `rejection.entityId` | Opens the normal editable detail of the current local row, which holds the server's winning version (or "missing" when `current` was null). The lost payload in `sync_rejections.rejected_payload` is never shown, so "what was lost" is limited to the summary line | +| S-16 | Dismiss it | Done | `SyncStatusScreen.kt` (around line 298) dismiss button; `SyncStatusViewModel.kt:56-57`; RepositoriesTest `syncStatus_listsPendingRowsAndRejections_andDismisses` | | +| S-16 must-show | Clock-skew warning, "Sync failed", stale copy naming `Rejection.actor` | Done | `SyncStatusScreen.kt:141`; `SyncStatusViewModel.kt:29`; RepositoriesTest `syncStatus_aRejectionNamesWhoMadeTheServersVersion` | | +| S-17 | Update | Done | `HomeScreen.kt:115-119,240-241`; `AboutViewModel.kt:61` | | +| S-17 | Later (hides the banner for the ViewModel's lifetime) | Done | `HomeViewModel.kt:68,88` | | +| S-17 must-show | Version name, "Install unknown apps" dialog once, progress, failure | Done | `HomeScreen.kt:154,276-287`; goldens S-17 update, progress and failed | | +| S-18 | Pair again (goes to S-1); back closes the app | Done | `KvittApp.kt` REMOVED (`BackHandler { activity?.finish() }`, `replaceAll(Routes.PAIRING)`) | | +| S-19 | Add | Done | `KvittApp.kt` RECURRING `onNewTemplate`; StateHolderTest `newTemplateDefaultsAndSave` | | +| S-19 | Edit | Done | `KvittApp.kt` TEMPLATE_EDITOR; StateHolderTest `editTemplateLoadsSplitAndMarksServerErrors` | | +| S-19 | Pause/Resume | Done | `RecurringEditorViewModel.kt:70,187` (switch plus resume hint) | Only from inside the editor; the list has no direct pause action. The spec does not require one | +| S-19 | Delete | Done | `RecurringEditorViewModel.kt:221,250-254` | | +| S-20 | Revoke (with confirmation) | Done | `devices/DevicesViewModel.kt:89-103`; `canRevoke` (AC-3.2); StateHolderTest `devicesLoadFailAndRevoke`; PureRulesTest `revokeRules` | Revoked devices are filtered out of the list (`DevicesViewModel.kt:76`) | +| S-21 | Create (partner before the join; device for either user) | Done | `invites/InvitesViewModel.kt` `invitePartner`, `inviteDevice`; `InvitesScreen.kt:185-186`; StateHolderTest `invitesCreateAndRefuse` | Admin only (`SettingsHubViewModel.kt:45-49`), as E-12 requires | +| S-21 | Share (Android share sheet) | Done | `InvitesScreen.kt:80-91` (`ACTION_SEND`, `createChooser`) | | +| S-21 | Cancel a pending invite | Done | `InvitesViewModel.kt` `askCancel`, `cancel` | | +| S-21 must-show | Code in groups of 4, link, expiry | Done | `InvitesScreen.kt:192-214` | | +| S-22 | Check for update | Done | `about/AboutScreen.kt:106-117`; `AboutViewModel.kt:43-59` | Hidden in debug builds | +| S-22 must-show | Version name and code, server URL, server version "(from /health)" | Partial | `data/.../repo/SettingsRepositories.kt:336-345`; `data/.../api/KvittApi.kt:241` (`X-Kvitt-Server-Version`) | The server version comes from the `X-Kvitt-Server-Version` response header of the last request, not from E-1 `/health` as §13.3 states. It is null until a request reaches the server. The value is the same crate version | +| §14.2.4 | UI contract types and interfaces in `:core:model` match the spec block | Done | `core/model/src/main/kotlin/se/hagstedt/kvitt/model/*.kt` (Ids, Enums, Records, Session, Home, Ledger, Accounts, Expenses, Settlements, Analytics, Settings, Sync, Push); `core/model/src/test/.../ContractTest.kt` | Every type, member, parameter and enum order checked matches, including the v0.13 additions (Pairing, lastAttemptFailed, Rejection.actor, appVersionName, NewRecurringTemplate, UpdateCheck, untouched, PushEvent). No differences found | + diff --git a/docs/validation/security-review.md b/docs/validation/security-review.md new file mode 100644 index 0000000..02fe955 --- /dev/null +++ b/docs/validation/security-review.md @@ -0,0 +1,139 @@ +> Taken at `18c527f` (main, 2026-10-18, SPEC v0.17); committed verbatim with SPEC v0.18. Status of each finding: SPEC §11.4. + +# Security review of Kvitt at `18c527f` (2026-10-18) + +Read-only review of the main checkout against SPEC v0.17 §8, §10, §14.3, §14.4, §14.6 and general practice. Scope: `server/crates/**`, `android/**`, `infra/**`, `contracts/openapi.yaml`, `.gitea/workflows/**`. No build, no phone, no network, no repository change. Threat model as in SPEC §8.1: one public port (8443) on a home connection, two trusted users, attacker is an internet stranger or a lost phone. Aggregates only; no secret values were read. + +## 1. Verdict + +**No critical finding. One High: the in-memory rate limiter can be made to grow without bound and to bypass the per-IP limit by an unauthenticated stranger (DoS of the Pi). Fix it before port 8443 is opened.** Everything else is Medium or below, mostly hardening and owner decisions. The design (hashed 256-bit device tokens, 60-bit single-use invite codes with a 10-per-hour failure lockout, Keystore-sealed token on the phone, no backup/extraction, one exported activity, parameterised SQL everywhere, path-safe APK and picture files, read-only non-root server container behind Caddy) matches the spec and is sound. + +Counts: Critical 0, High 1, Medium 3, Low 10, Info 8. + +## 2. Findings + +### High + +**H-1. Rate limiter: a random bearer token becomes "accepted" on any non-401 response, bypasses the IP limit and is never pruned.** +Location: `server/crates/kvitt-server/src/api/rate_limit.rs:273-287` (admit), `:305-314` (record), `:143-144` (prune), with `src/router.rs:102` (`require_protocol` runs before the token check) and `src/auth.rs:65-76`. +Issue: for an authenticated route the limiter keys by the hash of any token-shaped bearer (`Key::Token`). `admit` only *checks* the IP window for a not-yet-accepted token, it does not count against it; `record` counts the IP only when the response is 401, and otherwise marks the token `accepted = true`. A request with a random `kvt_` token and **no `X-Kvitt-Protocol` header** gets 426 from `require_protocol` (before the database is touched), so the random token is marked accepted; `prune` keeps accepted tokens forever. The same happens with 405 (wrong method on a known path). +Exploit (internet stranger, no credentials): send `GET /me` with a fresh random token and no protocol header in a loop. No IP counter is ever incremented, so there is no rate limit at all on this path, and every request adds a permanent `HashMap` entry (about 150 bytes) to a container limited to 160 MiB: a few hundred thousand requests, well within an hour on a home uplink, exhaust the server's memory (OOM kill, restart loop). Secondary effect: each such "accepted" token then gets its own 600/min bucket of database lookups on later requests with the protocol header, so the per-IP 60/min limit of §8.6 no longer bounds the database load from one IP. +Smallest fix (server): in `record`, mark a token accepted only when `status.is_success()` (2xx), not on every non-401/429; in `admit`, for a not-yet-accepted token *hit* the IP window (count it) instead of only checking it; in `prune`, also drop accepted tokens whose window has expired (re-marking costs one successful request). Add a test: random token + no protocol header × N leaves `tokens.len()` bounded and returns 429 after 60. + +### Medium + +**M-1. Backups on Google Drive are unencrypted and the rclone token has full-Drive scope.** +Location: `infra/backup/backup.sh:55-72`; SPEC §8.7 last bullet vs §8.1 ("a compromised backup location" is in scope); SPEC §10.9 (`scope = drive`). +Issue: the daily `kvitt-*.sqlite.gz` (all expenses, notes, names) and pictures go to Drive in clear; the mounted `rclone.conf` carries an OAuth refresh token with the `drive` scope, i.e. read/write to the owner's entire Drive, not just the backup folder. A compromise of the backup container or of `/opt/kvitt/secrets/rclone.conf` exposes the whole Drive; a compromise of the Google account exposes every backup. The spec accepts the first half (§8.7) while listing the second half as a threat (§8.1). +Smallest fix (infra + owner decision): wrap the remote in an rclone `crypt` remote (`[gdrive-crypt] type = crypt, remote = gdrive:`; the two passwords live in the already-secret `rclone.conf`, and `RESTORE.md` gains one `rclone copy` through the crypt remote); re-authorise with scope `drive.file` so the token reaches only files rclone created. Needs a decision entry and a §8.7/§10.9 edit. + +**M-2. The cert-sync SSH key may be a root login on the Home Assistant Pi.** +Location: `infra/cert-sync/sync.sh:36-42`; SPEC §10.5.1 ("variant B: added as a plain key"), `.env.example` ("the Home Assistant SSH add-ons log in as root"). +Issue: cert-sync holds `/secrets/ha_ssh_key`. With variant B there is no `restrict,command=` on the HA side, so whoever holds the key (a compromised cert-sync container, or the key copied around on the Mac and the Pi) gets an unrestricted root shell on the Home Assistant Pi, which also owns ports 80/443 and the house's automation. The script's own fixed `tar` command does not restrict the server side. +Smallest fix (infra + owner): make variant A the only accepted variant (`restrict,command="tar -C -cf - fullchain.pem privkey.pem"`, plus `from=""`), and if the add-on cannot do that, use a dedicated non-root user that can read `/ssl` only. Record the variant in `infra/README.md` as §10.5.1 already asks. + +**M-3. Picture decoding can exceed the server's memory limit (8000 × 8000 px vs 160 MiB).** +Location: `server/crates/kvitt-server/src/api/pictures.rs:39` (`MAX_DIMENSION`), `:66-92` (`process`), `infra/pi/compose.yaml` (`mem_limit: 160m`); limit from SPEC AC-4.4. +Issue: the dimension check happens before decoding (good), but an 8000 × 8000 image is allowed: decoding to RGBA is 256 MB, plus `to_rgba8`/`to_rgb8` copies, on a container limited to 160 MiB. A 5 MiB PNG or WebP easily encodes that size (a flat colour compresses to a few KB). Only an authenticated device can call `PUT /me/picture`, so in this threat model the attacker is a lost phone with a live token (until revoked) or a bug in the app, not a stranger; the effect is a crash and restart of the server (and of the sync for both phones) on each upload. +Smallest fix (server, plus a spec number): decode through `image::ImageDecoder` limits (`Limits { max_image_width/height, max_alloc ~ 64 MiB }`) or lower the limit to a pixel count (for example 16 M pixels, 4000 × 4000) and refuse larger with `image_invalid`. The number is a spec value: `TODO(spec)` / decision entry. + +### Low + +**L-1. Caddy's access log records invite codes and the admin's display name.** +Location: `infra/pi/Caddyfile:11-14`; SPEC §8.7 (the Rust server logs the route template, by design, `src/router.rs:134-142`). +Issue: Caddy logs `request.uri` in full: `/i/?from=` and the `?from=` value, together with the client IP and User-Agent, into the json-file logs on the Pi (10 MB × 3). Codes live 72 h and are single-use, so the risk is low (an attacker with Pi log access is out of scope), but it is personal data and a secret in a log that §8.7 says must not hold either. +Fix (infra): add `log_skip` (Caddy ≥ 2.8) for the `/i/*` matcher, or `log { format filter { request>uri delete } ... }` — one line in the Caddyfile; or drop the access log entirely (the server already logs every request). + +**L-2. The Caddy container is not hardened like the other three.** +Location: `infra/pi/compose.yaml` (`caddy` service: no `user`, no `cap_drop`, no `read_only`); SPEC §8.9 hardens only the server. +Issue: Caddy runs as root with every default capability and a writable root filesystem, and its admin API is bound to `0.0.0.0:2019` for every container on the `kvitt` network (only cert-sync needs it). This is the process that terminates TLS from the internet. +Fix (infra): `cap_drop: [ALL]`, `cap_add: [NET_BIND_SERVICE]` (or none: 8443 is unprivileged), `read_only: true` with `tmpfs: [/tmp]` and `caddy_data`/a `caddy_config` volume writable; keep `no-new-privileges`. Admin on `0.0.0.0` is required for cert-sync; acceptable on the internal network. + +**L-3. APK download is unauthenticated and 11 MB per request at 60 requests per minute per IP.** +Location: `server/crates/kvitt-server/src/api/apk.rs:76-101`, `src/api/rate_limit.rs:56-63`; SPEC §8.6, §10.6. +Issue: the only large unauthenticated response. One IP may pull about 660 MB/min, several IPs more, saturating the home uplink (and the Pi's SD card) for as long as they like. The file is public by design (the landing page links it). +Fix (server; the number is a spec value): a separate, lower window for `/apk/files/{file_name}` (for example 5 per minute per IP) — `TODO(spec)`. + +**L-4. `fcm_token` has no length limit.** +Location: `server/crates/kvitt-server/src/api/devices.rs:95-117`; SPEC §9.6 has no limit for it. +Issue: an authenticated device can store a string of up to 1 MiB, which is then sent to FCM in every push body for that device (and kept in the database and backups). Trusted users only; a Firebase Installation ID is 22 characters. +Fix (server, spec number): refuse more than, say, 256 characters with `validation_failed` (`TODO(spec)`). + +**L-5. The development stack mounts every secret into the server container.** +Location: `infra/dev/compose.yaml` (`${KVITT_SECRETS_DIR:-../secrets}:/secrets:ro`); SPEC §10 (accepted as the dev layout). +Issue: the dev server container sees the HA SSH key, `rclone.conf`, the Gitea push key and `google-services.json`, although it needs only `fcm-service-account.json`. The Pi stack mounts the single file (correct). +Fix (infra): mount `${KVITT_SECRETS_DIR}/fcm-service-account.json:/secrets/fcm-service-account.json:ro` as on the Pi; a missing file is already handled (push disabled). + +**L-6. Any link can prefill any server URL on the pairing screen.** +Location: `android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt:53-56`, `android/feature/onboarding/.../InviteLink.kt:36-52` (`fromAppLink`, `fromLandingLink`), manifest intent filters `android/app/src/main/AndroidManifest.xml:26-47`; SPEC AC-2.6, D-084. +Issue: `kvitt://invite?server=https://attacker&code=…` from any app or web page, or `https:///i/`, opens S-1 with the attacker's server in the field. An unpaired phone (first install, or after a revoke) could be talked into pairing with a rogue server, which then receives everything the phone pushes. Mitigations in place: a paired phone ignores the link (D-084, the session gate only shows S-1 when unpaired); the URL is visible and editable; release builds require `https://`. +Fix (android, small): on S-1 show a warning line when the prefilled server is not the default `https://icehack.asuscomm.com:8443` ("Unusual server"); no server change. + +**L-7. Notification text (description, amount) shows on the lock screen.** +Location: `android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt:82-84` (no `setVisibility`/`setPublicVersion`). +Issue: with Android's default "show sensitive content" setting a bystander reads expense descriptions and amounts from a locked phone. Spec §13.3 defines the copy; it does not say what the lock screen shows. +Fix (android): `setVisibility(VISIBILITY_PRIVATE)` with `setPublicVersion(...)` carrying the generic text already defined for the sync-failed case. + +**L-8. The Gitea workflow expands the registry token inside a shell line.** +Location: `.gitea/workflows/build-images.yaml` ("Log in to Gitea Container Registry": `echo "${{ secrets.kvitt_cicd }}" | docker login …`). +Issue: `${{ }}` is substituted into the script text before the shell runs it: a token containing a quote or `$` breaks the line (and could inject), and the value appears in the step's process arguments on the runner. The token also needs `write:repository` (for the tag push) on top of `write:package`. +Fix (infra): pass it as `env: { REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }} }` and use `printf '%s' "$REGISTRY_TOKEN" | docker login …`; keep the scopes minimal (the tag push can use the checkout token if Gitea provides one). + +**L-9. Images are pulled by tag over plain HTTP from the LAN registry.** +Location: `infra/pi/compose.yaml` (`${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}`), `infra/pi/install.sh` (`insecure-registries`), `infra/pi/deploy.sh`; SPEC §10.3, D-001/D-104 (LAN is trusted). +Issue: anyone on the home Wi-Fi who can answer for `gitea.lan` (or the Gitea Pi itself, if compromised) can hand the Kvitt Pi a tampered server image that sees every token hash and all data. Accepted by the spec's trust in the LAN; noted because the server is the one component that holds everything. +Fix (infra, optional): have `deploy.sh` pin the digest the workflow printed (`image@sha256:…` in `.env`), or enable TLS on Gitea later. + +**L-10. A member can tell whether a device id belongs to the other user.** +Location: `server/crates/kvitt-server/src/api/devices.rs:57-63` (404 for unknown, 403 for another user's device). +Issue: trivial information leak between the two trusted users; listed for completeness. +Fix (server): return 404 in both cases, or leave as is. + +### Info + +**I-1.** `X-Kvitt-Server-Version` on every response and `GET /health` (`server_version`, `schema_version`, `db_ok`) are visible to unauthenticated scanners (`src/router.rs:125-131`, `src/api/health.rs`). Spec-mandated; the version is not a secret. Accept. + +**I-2.** Room database and Coil's picture cache are unencrypted at rest (`android/data/.../di/DataModule.kt`, `repo/Pictures.kt`); the token key is Keystore AES-GCM without `setUnlockedDeviceRequired`/StrongBox (`data/auth/TokenCipher.kt:50-58`). This relies on Android file-based encryption and the lock screen, as §8.2 intends; the lost-phone case is covered by revocation (AC-3.3). Accept. + +**I-3.** The landing page sends no `Content-Security-Policy`/`X-Content-Type-Options` (`src/api/landing.rs:196-210`). Output is escaped and the page is static with inline CSS only; `Cache-Control: no-store` and `Referrer-Policy: no-referrer` are set. Optional one-liner: `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'`. + +**I-4.** No vulnerability scan of Rust dependencies is part of the server gate (no `cargo audit`/`cargo deny` in `tools/` or the hooks); `tools/dependency-report/latest.py` covers the Android catalog only. This review did not run one (offline). Suggest `cargo audit` as a periodic owner step or in the gate. + +**I-5.** Client-IP correctness to verify after deploy: the rate limiter trusts the last `X-Forwarded-For` entry from Caddy (`rate_limit.rs:203-227`). If Docker's userland proxy ever fronts port 8443 (IPv6 without `ip6tables`, or `userland-proxy` quirks), Caddy sees the bridge gateway for every client and all internet clients share one 60/min bucket and one 10-failed-accepts lockout. Check once in Caddy's log that `request.remote_ip` is a public address. + +**I-6.** `bootstrap` and `pair-device` print the invite code to stdout (`src/cli/bootstrap.rs:41-62`). Through `docker compose run --rm` the line also sits in that one-off container's json log until the container is removed. Expected; the owner reads it and it expires in 72 h. + +**I-7.** The Ktor/OkHttp client keeps default redirect handling (`data/api/KvittApi.kt:246-248`). Neither the server nor Caddy redirects, and TLS excludes a third party, so the bearer cannot be bounced to another host. Fine. + +**I-8.** `infra/secrets/` on the Mac: directory mode 0700; private key files and the service-account, rclone and `google-services.json` files mode 0600; two `.pub` files 0644. One file (`gitea_push_ed25519`) is not among the secrets SPEC §10 lists; presence only, not read. + +## 3. Checked and found fine + +* **Tokens (§8.2):** 32 CSPRNG bytes via `getrandom`, `kvt_` + base64url, stored only as SHA-256 hex (`api/invites.rs:79-86`, `auth.rs:46-53`); lookup by `UNIQUE token_hash` (`migrations/0001_init.sql:29`); revoked → `device_revoked` before any work; token never logged (log line carries route template, status, latency, device id only, `router.rs:134-162`); the hourly touch is in a `BEGIN IMMEDIATE` transaction. +* **Invite codes (§8.3, AC-2.3, AC-2.12):** 60 random bits from the CSPRNG, Crockford alphabet, normalisation and hashing as specified; single-use/expiry/cancel checked inside the write transaction; every failure is the same `invite_invalid`; 10 failed accepts per IP per hour then 429 (`rate_limit.rs:46-49, 251-265`); `admin_setup` refused once a user exists; `household_full` enforced. +* **Authorization (§8.4):** `AdminDevice` on create/list/cancel invites; devices listing filtered for members; revoke rules and `cannot_revoke_current_device`; `/me` edits only the caller; pushed expenses/settlements must name household users (`sync/check.rs:92-110, 185, 204-222`). +* **SQL:** every query uses `sqlx` bind parameters; no string-built SQL found in `server/crates`; path ids must be canonical hyphenated UUIDs (`api/mod.rs:95-113`). +* **Files and paths:** APK names must match `^kvitt-[0-9]+\.apk$` and be regular files (`apk.rs:22-26, 80-92`); picture paths are built from a parsed UUID and an integer version; writes are atomic (temp + rename); `latest.json` is read on each request, never cached stale. +* **Body limits:** 1 MiB default and 5 MiB for `PUT /me/picture` in the router; Caddy `max_size 6MB`; sync batch 1 to 100 mutations, pull page ≤ 1000; notes/description/name limits applied after trimming; control characters refused in notes. +* **Error leakage:** `internal` returns only a request id to the client and logs the detail (`error.rs:225-234`); 404 for unknown paths; serde messages reach the client only for the caller's own malformed JSON. +* **Panics/unsafe:** `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]` in `lib.rs` and `main.rs`; `unsafe_code = "forbid"` in the workspace; mutex poisoning handled (`rate_limit.rs:164-169`). +* **Push (§14.6):** data-only payloads with ids only; service-account `Debug` never prints the key (`push/fcm.rs:64-71`); access token cached in memory only; `UNREGISTERED` clears the token only if unchanged; log lines carry device id and push type, never the FCM token. +* **Server container (§8.9):** uid 10001, `read_only`, `cap_drop: [ALL]`, `no-new-privileges`, no published port, distroless runtime pinned by digest, single secret file mounted read-only; only Caddy publishes 8443; TLS from files, `auto_https off`, admin API not published; Caddy ≥ 2.11 defaults give TLS 1.2+ and the `Authorization` header is redacted (verified by the owner 2026-10-18 per §10.5.2). +* **cert-sync:** `StrictHostKeyChecking=yes` with a dedicated known_hosts, `BatchMode`, `IdentitiesOnly`, fixed remote command, PEM/key-match/expiry checks before install, atomic rename, uid 1000, `cap_drop: [ALL]`. +* **Backups:** online `.backup` + `integrity_check`; copy-only with `--immutable`, confirmed by `lsf` before local deletion; `rclone.conf` used through a 0600 `mktemp` copy that is removed on exit; log lines carry names and sizes only. +* **Secrets in git:** `.gitignore` covers `local.properties`, `*.jks`, `infra/secrets/`, `*.pem`, `*service-account*.json`, `google-services.json`, `*.sqlite*`, `infra/pi/.env`, `infra/pi/out/`, `infra/pi/secrets/`; the pre-commit hook refuses those patterns; `git ls-files` and the add-history of all branches contain no secret-looking file; `.env.example` files hold placeholders only (the Drive folder id is not a secret). +* **Android (§8.5, §8.8):** release network config: cleartext off, system anchors; pinned-root variant limited to one domain and only with `kvitt.tls.pinnedRoot=true`; debug cleartext confined by `normaliseServerUrl` to `https://`, `localhost`, `10.0.2.2` and the three private ranges (`SessionRepositoryImpl.kt:102-124`); `allowBackup="false"` and extraction rules excluding every domain for both cloud backup and device transfer; exported components: `MainActivity` only; `KvittMessagingService` and `InstallResultReceiver` not exported; WorkManager default initializer removed in favour of the Hilt factory. +* **Android token storage (§8.2):** Keystore AES-256-GCM, alias `kvitt_token_key`, 128-bit tag, Keystore-generated IV, ciphertext+IV in `auth.preferences_pb`; `Credentials.toString` redacts the token; on `device_revoked` the preferences are cleared, the key deleted, and database, prefs and picture caches wiped (`SessionManager.kt`, `DataModule.kt:166-170`); on `unauthorized` only the token is dropped. +* **Android network:** bearer only on authenticated calls and, for Coil, only on URLs under the paired base URL (`PictureAuth.kt`); the token is never put in a URL; the accept call sends no bearer; log statements in `app`, `data` and `adapter` carry status codes and exception class names only. +* **In-app update (FR-16):** release-only; file name validated against the same regex as the server; download to app-private cache; size bounded during download and SHA-256 + size verified before `PackageInstaller`; file deleted on any failure; Android enforces signing-key continuity on the update, so a tampered APK from a compromised server could not replace the installed app; `canRequestPackageInstalls` explained once; R8 enabled; `mapping.txt` never served. +* **Push handling on the phone:** untrusted FCM data mapped to a typed event with defaults (`PushMapping.kt`), unknown `type`/`v` → sync only; ids are passed to WorkManager as strings and looked up in Room, never rendered raw. +* **CSV import:** runs only as an operator CLI against a file path given on the command line, inside one transaction, with header and row validation and a reconciliation gate; not reachable over HTTP. +* **OpenAPI:** top-level `security: [device]`; exactly the six public operations (E-1 to E-6) declare `security: []`; the server router applies `AuthDevice`/`AdminDevice` on every other route. + +## 4. Owner questions + +1. **Backups (M-1):** encrypt the Drive backups with an rclone `crypt` remote (passwords in `rclone.conf`, restore documented), and re-authorise rclone with the `drive.file` scope? This changes §8.7's last bullet and §10.9 and needs a decision entry. +2. **HA SSH key (M-2):** which `authorized_keys` variant did the Home Assistant add-on accept at checklist step 3? If B (plain key), is a dedicated non-root user possible on that Pi? +3. **Picture limit (M-3):** may AC-4.4's 8000 × 8000 become a pixel-count limit (for example 16 M pixels) or a decoder allocation limit, given the 160 MiB container? +4. **APK download rate (L-3) and `fcm_token` length (L-4):** are a separate per-IP limit for `/apk/files/*` and a 256-character cap acceptable as new §8.6/§9.6 numbers? +5. **After the first deploy (I-5):** one check of Caddy's access log that `request.remote_ip` shows public client addresses, so the per-IP limits key on real clients. diff --git a/prompts/README.md b/prompts/README.md index 0d6eb3a..b866e6a 100644 --- a/prompts/README.md +++ b/prompts/README.md @@ -2,7 +2,7 @@ One ready-to-paste start prompt per plan (`PLAN-.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.17). Status as of 2026-10-18: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`fdf2093`), M2b landed (`fad6fc9`), M3 landed (`503cee5`), M5 landed (`c28c52b`), M4 landed (`bd6f10a`), M9 landed (`ab298ab`); SPEC v0.7 landed (`e8ee518`); M12a landed (`6b60ef7`); SPEC v0.8 landed (`7fb9f6f`); M12c landed (`e499107`); M12b landed (`2e2ebe7`); U1 and U2 landed (`16c4d01`, owner approved); SPEC v0.9 landed (`45eecd1`); M12d landed (`bdba9d3`); M6 landed (`3080804`); SPEC v0.10 landed (`7bd0aa1`); M7a landed (`b9b061a`), M7e (`6ac711d`), M7d (`082b8dc`), M7b (`31760f9`), M7c (`1e42f76`), M7f (`a54eb7c`); M6b `:core:design` fixes landed (`3a1bd14`); SPEC v0.11 landed (`d504004`, the M7 and M6b outcomes, D-069 to D-080); M7g feature cleanups landed (`34a2590`, no prompt file; brief by the orchestrator); M8 landed (`369e516`, `00a14a3`), its on-phone test still to run; SPEC v0.12 records the M8 outcomes (D-081 to D-085); the M12e contract pass (no prompt file; brief by the orchestrator) landed (`1470a2f`, `90505a8`); SPEC v0.13 records its outcomes (D-086 to D-095, server schema 3) and landed (`03b7184`); the Vico removal (`2b4e21c`), the FCM `fid` target (`c694f78`) and the Pi image arm64 check (`1e404cc`) landed; SPEC v0.14 records them (D-096); the M8 phone test ran on 2026-10-18 and M8b UI fixes landed (`f0575d3`, D-097) with the `fid` follow-up (`09f474f`); SPEC v0.15 records them and D-100 (M2c-pi as an owner-run bundle) and D-101 (keystore at the repository root). 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); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043); v0.6 the M4 decisions (D-035 to D-039); v0.7 the M9 decisions (D-044 to D-046); v0.8 the owner's UX-session decisions (D-047 to D-054) and the milestone group M12; v0.9 D-055 to D-059 and M12d; v0.10 D-060 to D-068; v0.11 D-069 to D-080; v0.12 D-081 to D-085; v0.13 D-086 to D-095; v0.14 D-096; v0.15 D-097, D-100, D-101; after v0.15 M8c (`b481fab`, `64f5706`, `7c453d8`; goldens in three variants at 1x, the S-22 licences row removed), the R8 release build (`57f984f`) and M8d (`3c68594`, S-7 editor rows) landed; SPEC v0.16 records them (D-098, D-099, D-102) and landed (`3a3720b`); the M2c-pi bundle (`5e027df`, D-103 to D-106), M8e (`15397e4`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`965bb5d`), M8f (`ed1b130`, D-108, instant navigation) and the chips cleanup (`4755fe0`) landed; SPEC v0.17 records them (D-103 to D-108); what remains of M2c-pi is the owner running the bundle on the Kvitt Pi, then M10; v0.16 D-098, D-099, D-102; v0.17 D-103 to D-108. +Dependencies follow SPEC §11.1 and §11.2 (v0.18). Status as of 2026-10-18: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`06f48ef`), M2b landed (`c277ee3`), M3 landed (`7869ec4`), M5 landed (`8f0e156`), M4 landed (`15056dc`), M9 landed (`50339ae`); SPEC v0.7 landed (`86e08a1`); M12a landed (`aa765bb`); SPEC v0.8 landed (`d39eb2c`); M12c landed (`b09f322`); M12b landed (`6eaccec`); U1 and U2 landed (`6da6b1b`, owner approved); SPEC v0.9 landed (`e676812`); M12d landed (`557af7e`); M6 landed (`7730a31`); SPEC v0.10 landed (`9ee67d4`); M7a landed (`1ea752c`), M7e (`ea7d494`), M7d (`7380e50`), M7b (`0e1d99d`), M7c (`0b140aa`), M7f (`c4ab35f`); M6b `:core:design` fixes landed (`b24c8b5`); SPEC v0.11 landed (`f4dc10c`, the M7 and M6b outcomes, D-069 to D-080); M7g feature cleanups landed (`3dda414`, no prompt file; brief by the orchestrator); M8 landed (`cb832a7`, `3d9eea8`), its on-phone test still to run; SPEC v0.12 records the M8 outcomes (D-081 to D-085); the M12e contract pass (no prompt file; brief by the orchestrator) landed (`8c60715`, `b7b7483`); SPEC v0.13 records its outcomes (D-086 to D-095, server schema 3) and landed (`51d6ff8`); the Vico removal (`a312758`), the FCM `fid` target (`f56dcbe`) and the Pi image arm64 check (`3e6c337`) landed; SPEC v0.14 records them (D-096); the M8 phone test ran on 2026-10-18 and M8b UI fixes landed (`328c466`, D-097) with the `fid` follow-up (`6eb3f85`); SPEC v0.15 records them and D-100 (M2c-pi as an owner-run bundle) and D-101 (keystore at the repository root). 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); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043); v0.6 the M4 decisions (D-035 to D-039); v0.7 the M9 decisions (D-044 to D-046); v0.8 the owner's UX-session decisions (D-047 to D-054) and the milestone group M12; v0.9 D-055 to D-059 and M12d; v0.10 D-060 to D-068; v0.11 D-069 to D-080; v0.12 D-081 to D-085; v0.13 D-086 to D-095; v0.14 D-096; v0.15 D-097, D-100, D-101; after v0.15 M8c (`75b549f`, `a0c25b0`, `7c453d8`; goldens in three variants at 1x, the S-22 licences row removed), the R8 release build (`c30a49e`) and M8d (`bacbd18`, S-7 editor rows) landed; SPEC v0.16 records them (D-098, D-099, D-102) and landed (`bfc3acb`); the M2c-pi bundle (`2476667`, D-103 to D-106), M8e (`a59abeb`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`b4d9467`), M8f (`956908a`, D-108, instant navigation) and the chips cleanup (`1328afe`) landed; SPEC v0.17 records them (D-103 to D-108); what remains of M2c-pi is the owner running the bundle on the Kvitt Pi, then M10; v0.16 D-098, D-099, D-102; v0.17 D-103 to D-108; after v0.17 the history rewrite and first push to `origin` (`18c527f`, all later hashes in this file are the rewritten ones), the infra overhaul (`2fac1ec` to `e70fe0d`), the server gap pass (`8660b7f`, D-113), the infra security fixes (`7fb86f5`, D-117) and M12f (`dc7a296` to `a0259ab`, D-109 to D-112, D-116; no prompt file, orchestrator brief) landed; SPEC v0.18 records them with the AC-coverage audit and the security review (`docs/validation/`); the server overhaul (`8103dae` to `b985ce9`, D-114, D-115) landed too and is recorded in v0.18; an Android overhaul runs. ## Dependency order and waves @@ -11,31 +11,33 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7 | Wave | Plan | Prompt | Depends on | Parallel with | |---|---|---|---|---| | 0 | M1 Contracts and vectors | `M1.md` | M0 | done (`c6df2ab`) | -| 0 | U1 UX directions | `U1.md` | M0 | done (`16c4d01`) | -| 1 | M2a Server skeleton and `kvitt-domain` | `M2a.md` | M1 | done (`fdf2093`) | -| 1 | M2b Android skeleton and `:domain` | `M2b.md` | M1 | done (`fad6fc9`); `installDebug` on the phone outstanding | +| 0 | U1 UX directions | `U1.md` | M0 | done (`6da6b1b`) | +| 1 | M2a Server skeleton and `kvitt-domain` | `M2a.md` | M1 | done (`06f48ef`) | +| 1 | M2b Android skeleton and `:domain` | `M2b.md` | M1 | done (`c277ee3`); `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 | U2 UX design spec | `U2.md` | U1 and the owner's pick | done (`16c4d01`) | -| 2 | M3 Server core API | `M3.md` | M2a | done (`503cee5`) | -| 2 | M4 Server sync, audit, push, scheduler | `M4.md` | M2a; lands after M3 (D-015) | done (`bd6f10a`) | -| 2 | M5 Android data layer | `M5.md` | M1, M2b | done (`c28c52b`) | -| 2 | M12a Category contract v2: contracts | `M12a.md` | M1, SPEC v0.8 | done (`6b60ef7`) | -| 2 | M12b Category contract v2: server | `M12b.md` | M12a (M3, M4, M9 landed) | done (`2e2ebe7`) | -| 2 | M12c Category contract v2: Android | `M12c.md` | M12a (M2b, M5 landed) | done (`e499107`) | -| 2 | M12d UI contract additions (UX-9) | `M12d.md` | M12c, SPEC v0.9 | done (`bdba9d3`) | -| 2 | M6 Design system | `M6.md` | U2 approved, M2b, M12c, the §9.7 colour spec change (v0.9) | done (`3080804`) | -| 3 | M7a Feature onboarding | `M7a.md` | M6, M12d | done (`b9b061a`) | -| 3 | M7b Feature home and account | `M7b.md` | M6, M12d | done (`31760f9`) | -| 3 | M7c Feature expense | `M7c.md` | M6, M12c, M12d | done (`1e42f76`) | -| 3 | M7d Feature settle | `M7d.md` | M6, M12d | done (`082b8dc`) | -| 3 | M7e Feature analytics | `M7e.md` | M6, M12d | done (`6ac711d`) | -| 3 | M7f Feature settings | `M7f.md` | M6, M12d | done (`a54eb7c`) | -| 3 | M9 Splitwise importer | `M9.md` | M2a, M4 | done (`ab298ab`) | -| 3 | M6b Design-system fixes after M7 | none (orchestrator brief) | M7a to M7f | done (`3a1bd14`) | -| 4 | M7g Feature cleanups | none (orchestrator brief) | M7a to M7f, M6b | done (`34a2590`) | -| 4 | M8 App integration | `M8.md` | M3, M4, M5, M7a to M7f, M2c-dev | done (`369e516`, `00a14a3`); on-phone test run 2026-10-18; M8b fixes `f0575d3`; M8c `b481fab`, `64f5706`, `7c453d8`; M8d `3c68594`; M8e `15397e4`; M8f `ed1b130`; chips cleanup `4755fe0` (no prompt files; briefs by the orchestrator) | -| 4 | M12e Contract pass | none (orchestrator brief) | M8, SPEC v0.12 (§11.3 list) | done (`1470a2f`, `90505a8`) | -| 5 | M2c-pi Infra on the Pi (bundle `infra/pi/`, D-100) | `M2c-pi.md` | M2c-dev, M8; runs last, after the app is proven | bundle done (`5e027df`, D-103 to D-106); the owner's run on the Pi remains | +| 1 | U2 UX design spec | `U2.md` | U1 and the owner's pick | done (`6da6b1b`) | +| 2 | M3 Server core API | `M3.md` | M2a | done (`7869ec4`) | +| 2 | M4 Server sync, audit, push, scheduler | `M4.md` | M2a; lands after M3 (D-015) | done (`15056dc`) | +| 2 | M5 Android data layer | `M5.md` | M1, M2b | done (`8f0e156`) | +| 2 | M12a Category contract v2: contracts | `M12a.md` | M1, SPEC v0.8 | done (`aa765bb`) | +| 2 | M12b Category contract v2: server | `M12b.md` | M12a (M3, M4, M9 landed) | done (`6eaccec`) | +| 2 | M12c Category contract v2: Android | `M12c.md` | M12a (M2b, M5 landed) | done (`b09f322`) | +| 2 | M12d UI contract additions (UX-9) | `M12d.md` | M12c, SPEC v0.9 | done (`557af7e`) | +| 2 | M6 Design system | `M6.md` | U2 approved, M2b, M12c, the §9.7 colour spec change (v0.9) | done (`7730a31`) | +| 3 | M7a Feature onboarding | `M7a.md` | M6, M12d | done (`1ea752c`) | +| 3 | M7b Feature home and account | `M7b.md` | M6, M12d | done (`0e1d99d`) | +| 3 | M7c Feature expense | `M7c.md` | M6, M12c, M12d | done (`0b140aa`) | +| 3 | M7d Feature settle | `M7d.md` | M6, M12d | done (`7380e50`) | +| 3 | M7e Feature analytics | `M7e.md` | M6, M12d | done (`ea7d494`) | +| 3 | M7f Feature settings | `M7f.md` | M6, M12d | done (`c4ab35f`) | +| 3 | M9 Splitwise importer | `M9.md` | M2a, M4 | done (`50339ae`) | +| 3 | M6b Design-system fixes after M7 | none (orchestrator brief) | M7a to M7f | done (`b24c8b5`) | +| 4 | M7g Feature cleanups | none (orchestrator brief) | M7a to M7f, M6b | done (`3dda414`) | +| 4 | M8 App integration | `M8.md` | M3, M4, M5, M7a to M7f, M2c-dev | done (`cb832a7`, `3d9eea8`); on-phone test run 2026-10-18; M8b fixes `328c466`; M8c `75b549f`, `a0c25b0`, `7c453d8`; M8d `bacbd18`; M8e `a59abeb`; M8f `956908a`; chips cleanup `1328afe` (no prompt files; briefs by the orchestrator) | +| 4 | M12e Contract pass | none (orchestrator brief) | M8, SPEC v0.12 (§11.3 list) | done (`8c60715`, `b7b7483`) | +| 4 | M12f Audit and security follow-ups (Android) | none (orchestrator brief) | M8f, AC-coverage audit, security review | done (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`; D-109 to D-112, D-116) | +| 4 | Server gap pass; infra overhaul; infra security | none (orchestrator briefs) | M2c-pi bundle, security review | done (`8660b7f`, D-113; `2fac1ec` to `e70fe0d`; `7fb86f5`, D-117); server overhaul `8103dae` to `b985ce9` (D-114, D-115) | +| 5 | M2c-pi Infra on the Pi (bundle `infra/pi/`, D-100) | `M2c-pi.md` | M2c-dev, M8; runs last, after the app is proven | bundle done (`2476667`, D-103 to D-106); the owner's run on the Pi remains | | 6 | M10 First release | `M10.md` | M2c-pi, M3, M4, M8 | none | | 7 | M11 Import history | `M11.md` | M9, M10 | none | @@ -43,9 +45,9 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7 - **M3 and M4** (D-015): M2a provides `next_occurrence_date`, the `AuditWriter`/`PushQueue` traits with no-op implementations and `tasks::start`; M3 writes the recurring generation function and calls the traits; M4 replaces the implementations, its scheduler calls M3's function, and M4 lands after M3. - **M2a and M2b** create shell files and modules in later milestones' paths (D-004); ownership passes on when they land. -- **M7a to M7f** (landed) follow the feature convention of SPEC §6.2 (D-017) and take the ledger rows and expense editor fields from M6's `:core:design` (D-019); their readings are D-069 to D-074 and D-080, the shared fixes went into M6b (`3a1bd14`, D-075 to D-079). Their contract requests are on the M12e list in SPEC §11.3. +- **M7a to M7f** (landed) follow the feature convention of SPEC §6.2 (D-017) and take the ledger rows and expense editor fields from M6's `:core:design` (D-019); their readings are D-069 to D-074 and D-080, the shared fixes went into M6b (`b24c8b5`, D-075 to D-079). Their contract requests are on the M12e list in SPEC §11.3. - **M8** (landed) wired the navigation of SPEC §13.2 (D-071) and deleted the debug fakes module; both variants bind `:data` (D-081). Its outcomes are D-081 to D-085; its contract requests are on the M12e list in SPEC §11.3. -- **M6** landed `3080804` (D-063 to D-066): charts on `Canvas`, Expressive fallbacks against material3 1.4.0, formatting tables in code, one golden per sheet × theme × language (+200 %), which M7a to M7f follow. Its M8 contract requests are in SPEC §11.3. -- **M12** (D-054): done; the importer test `category_table_matches_the_spec` is green again since M12b (`2e2ebe7`). M12d (D-059) landed `bdba9d3` (D-060 to D-062); M7a to M7f run on top of both. +- **M6** landed `7730a31` (D-063 to D-066): charts on `Canvas`, Expressive fallbacks against material3 1.4.0, formatting tables in code, one golden per sheet × theme × language (+200 %), which M7a to M7f follow. Its M8 contract requests are in SPEC §11.3. +- **M12** (D-054): done; the importer test `category_table_matches_the_spec` is green again since M12b (`6eaccec`). M12d (D-059) landed `557af7e` (D-060 to D-062); M7a to M7f run on top of both. - **Phone:** M2b (one `installDebug`), M5 (only if instrumented tests), M8 and M10 use the phone; only one at a time, named in the notes. M8's on-phone test was run by the orchestrator with the owner on 2026-10-18; M12e did not use the phone; its APK needs a schema-3 server (SPEC §14.8), and the dev server already runs it. - **Outside the repository:** M2c-dev (D-022, done), M2c-pi needs none: it writes the `infra/pi/` bundle and the owner runs it on the Pi (D-100, revises D-023); M8 (dev server on the Mac), M10 and M11 (production) need the owner's allowance in the notes.