SPEC v0.12: M8 outcomes (D-081 to D-085), HANDOFF, prompts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 14:39:23 +02:00
parent 3d9eea8235
commit 6159e18198
4 changed files with 84 additions and 59 deletions
+42 -31
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.11** (2026-10-18). Schema version **2**. Sync protocol version **1**. Push payload version **1**.
Version **0.12** (2026-10-18). Schema version **2**. 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,10 +120,10 @@ 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 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/<CODE>` 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=<urlencoded KVITT_PUBLIC_URL>&code=<CODE>`, 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. 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=…` and from `https://icehack.asuscomm.com:8443/i/<CODE>` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled.
* AC-2.6 The app opens from `kvitt://invite?server=…&code=…` and from `https://icehack.asuscomm.com:8443/i/<CODE>` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. 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.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.*`).
* AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android `POST_NOTIFICATIONS`).
* 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.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 continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen.
* AC-2.11 The admin sees pending invites (kind, created, expiry) in Settings and can cancel each.
* AC-2.12 More than 10 failed invite accepts from one client IP within 60 minutes yield `rate_limited` for the rest of that window. A failed accept is any 4xx answer of E-3 other than 429. After the tenth failure, further accepts from that IP are refused with `rate_limited` until 60 minutes after the first counted failure; the window is fixed from the first counted request, not sliding (D-031).
@@ -249,8 +249,8 @@ The server starts with an empty database. The household is created from the comm
* AC-13.3 The importer sends no pushes.
* 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, or Home for `sync_batch` and deletions.
* AC-13.7 The app registers its FCM token after pairing and whenever Firebase rotates it (`PUT /devices/current/push-token`).
* 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). When the device is revoked (AC-3.3) the app calls `unregister()`.
* AC-13.8 No personal content (names, amounts, descriptions, notes) is placed in the FCM payload.
### FR-14 Analytics
@@ -401,13 +401,13 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2).
| UI | Jetpack Compose, Material 3; Material 3 Expressive components where they are stable. The Material 3 artifact is pinned explicitly in the catalog (1.4.0 at M2b) alongside the Compose BOM. Against material3 1.4.0 only `ShortNavigationBar` of the Expressive set is public and used; every other Expressive API takes the fallback listed in D-064 (M6) | Native, modern, screenshot-testable |
| Min / target SDK | `minSdk` 36 (Android 16); `compileSdk` and `targetSdk` 37 (the latest stable API when M2b pinned them, in `libs.versions.toml`) | Both phones run Android 16; no legacy paths |
| Local DB | Room 3 (`androidx.room3`, 3.0.3 at M2b), KSP only, an `SQLiteDriver` with `androidx.sqlite:sqlite-bundled`, exported schema (D-012) | Full offline copy; migrations with tests |
| DI | Hilt. `:data`'s `DataModule` provides implementations only; the bindings of the `:core:model` interfaces to them live in `DataBindingsModule` (`@DisableInstallInCheck`), which `:app` includes in the release graph, so the debug fakes do not duplicate bindings (D-041) | Standard, works with WorkManager and ViewModels |
| DI | Hilt. `:data`'s `DataModule` provides implementations only; the bindings of the `:core:model` interfaces to them live in `DataBindingsModule` (`@DisableInstallInCheck`), which `:app` includes in both variants through `di/AppDataModule.kt` in `src/main` (D-041, revised by D-081) | Standard, works with WorkManager and ViewModels |
| HTTP | Ktor client (OkHttp engine), kotlinx.serialization JSON | Kotlin-first, multiplatform-style DTOs |
| Background | WorkManager (periodic 15 min sync, expedited sync) | Survives process death, respects Doze |
| 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, 10/4 corner radii on the whole bar and an axis gutter growing at 200 % that `docs/ux/design-spec.md` §3.10 requires; Vico stays in the version catalog unused, removable in a later cleanup | The three charts are small; the design spec's accessibility rules need per-bar control |
| Push | Firebase Cloud Messaging, only inside `:adapter:fcm` | Free tier; vendor isolated per `CLAUDE.md` |
| 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`) |
@@ -422,7 +422,7 @@ Module graph (directory `android/`):
| `: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: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`, token registration, maps messages to `PushEvent` (`:core:model`) | `:core:model` |
| `:adapter:fcm` | Android library | `FirebaseMessagingService`, registration by Firebase Installation ID (D-083), maps messages to `PushEvent` (`:core:model`; as built by M8 `PushEvent` still lives in `:adapter:fcm`, the move is on the M12e list of §11.3) | `:core:model` |
| `:feature:onboarding` | Android library | Pairing, partner profile step, waiting for partner, device removed | `:core:model`, `:core:design` |
| `:feature:home` | Android library | Home (global balance, accounts), sync status, update banner | `:core:model`, `:core:design` |
| `:feature:account` | Android library | Account detail, account editor | `:core:model`, `:core:design` |
@@ -434,7 +434,7 @@ Module graph (directory `android/`):
**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-066, M6): one golden per component sheet (or screen state) × light/dark × sv/en, named `<sheet>_<lang>_<theme>.png`, plus a `_200` variant at 200 % font scale for the long states; 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: each feature module has Roborazzi screenshot tests of its screen states with goldens in `src/test/screenshots/`, rendered full screen at 411 × 891 dp, xhdpi (as landed by M7a to M7f).
**Hilt wiring before M8.** `:app` includes `DataBindingsModule` (D-041) in the release graph since `e6ad858` (pre-M8, with `AppBuildInfo` from `BuildConfig` in both variants). Until M8, debug builds bind every `:core:model` interface to `FakeRepositories(FakeScenarios.empty)` in `android/app/src/debug/.../DebugFakesModule.kt` (`72f21b2`, temporary); M8 deletes that file, so both variants bind `:data`. Fakes stay in test and preview source sets only.
**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.
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`.
@@ -578,8 +578,8 @@ Two trusted users. The server is on the public internet on port 8443. Threats in
### 8.5 Transport
* TLS terminates in Caddy on port 8443 with the Let's Encrypt certificate for `icehack.asuscomm.com` (§10.5). TLS 1.2 minimum, Caddy defaults otherwise.
* Release builds' network security config: cleartext disabled, system trust anchors only, unless the fallback of §10.5.3 is active, in which case the pinned Caddy root replaces the system anchors for `icehack.asuscomm.com` only.
* Debug builds additionally allow cleartext to the hosts listed in AC-2.8.
* Release builds' network security config (`android/app/src/main/res/xml/network_security_config.xml`): cleartext disabled, system trust anchors only, unless the fallback of §10.5.3 is active, in which case the pinned Caddy root replaces the system anchors for `icehack.asuscomm.com` only. The pinned-root config is in `android/app/src/releasePinnedRoot/res`, added to the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true` (D-082).
* Debug builds override the config in `android/app/src/debug` and allow cleartext to every host, because a network security config cannot express IP ranges; the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended (D-082).
* The server trusts `X-Forwarded-For` only from the Caddy container (the request's peer address on the compose network) to get the client IP for rate limiting.
### 8.6 Rate limiting and input limits
@@ -603,7 +603,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in
### 8.8 Phone hardening
* `android:allowBackup="false"` and `android:dataExtractionRules` excluding everything, so neither Google backup nor device transfer copies the token or database.
* Exported components: only `MainActivity` (launcher and deep-link intent filters) and the FCM service as Firebase requires.
* Exported components: only `MainActivity` (launcher and deep-link intent filters) and library receivers guarded by a permission (Firebase's, as Firebase requires). The FCM service `KvittMessagingService` is not exported. Checked on the merged release manifest at M8.
* On `device_revoked` the app wipes local data as in AC-3.3.
### 8.9 Server hardening
@@ -975,7 +975,7 @@ Used only if the certificate sync cannot be made to work. Documented in `infra/R
* Caddyfile site block uses `tls internal` instead of the file pair.
* The Caddy root certificate (`/data/caddy/pki/authorities/local/root.crt` in `caddy_data`) is copied once to `android/app/src/release/res/raw/kvitt_root_ca.crt`. This file is a public certificate and is committed.
* The release network security config gets a `domain-config` for `icehack.asuscomm.com` whose trust anchors are only `@raw/kvitt_root_ca`. Switching is a build property `kvitt.tls.pinnedRoot=true` in `android/gradle.properties`.
* The release network security config gets a `domain-config` for `icehack.asuscomm.com` whose trust anchors are only `@raw/kvitt_root_ca`. Switching is a build property `kvitt.tls.pinnedRoot=true` in `android/gradle.properties`, which adds `android/app/src/releasePinnedRoot/res` to the release resources (D-082).
* Caddy's internal root is valid 10 years; intermediates renew automatically.
### 10.6 APK hosting and release
@@ -987,7 +987,7 @@ Used only if the certificate sync cannot be made to work. Documented in `infra/R
keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000
```
The file is never inside the repository or a worktree. `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 in their password manager.
The file is never inside the repository or a worktree. `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 in their password manager. Without these keys in `local.properties`, `:app:assembleRelease` builds an unsigned APK (M8).
* `infra/publish-apk.sh` on the Mac: builds `:app:assembleRelease` through `tools/gradle-serial.sh`, computes SHA-256 and size, copies the APK to the Pi's `data` volume as `apk/kvitt-<version_code>.apk`, writes `apk/latest.json` atomically (write temp, rename):
```json
@@ -1138,11 +1138,11 @@ M12a contracts + :domain suggestCategory (landed 6b60ef7) ─┬─► M12b serv
M12d UI contract additions (D-059: frequentCategoryIds, deletable; landed bdba9d3) ─► M7a..M7f (ran in parallel with M6, landed 3080804)
After the M7 wave (v0.11):
M7a..M7f (landed) ─► M6b core:design fixes (landed 3a1bd14) ─┬─► M8 app integration (started 2026-10-18)
└─► M7g feature cleanups (started 2026-10-18)
M7a..M7f (landed) ─► M6b core:design fixes (landed 3a1bd14) ─┬─► M8 app integration (landed 00a14a3)
└─► M7g feature cleanups (landed 34a2590)
```
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). 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) started 2026-10-18 in parallel (disjoint paths: `android/app/**` and `android/adapter/fcm/**` against `android/feature/**`).
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). 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 started 2026-10-18.
### 11.2 Milestone table
@@ -1171,8 +1171,8 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| 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 | Started 2026-10-18. Cleanups in `android/feature/**` after M6b; scope in its brief | as M7a | none | M7a to M7f, M6b |
| M8 | App integration | Started 2026-10-18 (in flight; decisions D-085 to D-089 reserved, renumbered to D-081 onwards on landing). `:app` navigation and deep links (§13.2, D-071), deletion of the debug fakes module (§6.2), Hilt bindings from interfaces to `:data` and `:domain`, `:adapter:fcm`, notification building (AC-13.5), update banner wiring, revoke wipe, network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), `google-services.json` copied with `infra/dev/copy-google-services.sh` | Android gate; on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced | Tap through the pairing and the offline test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) |
| 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 (not yet run at landing: phone not attached; HANDOFF §3) | Tap through the pairing, offline and push test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) |
| 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 |
| 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, outside the repository; install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi, 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 |
@@ -1183,9 +1183,9 @@ 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/<feature>/` and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from `:core:design` (D-019). 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 (started 2026-10-18) does the feature-side cleanups after M6b.
* **M7x** features use `:core:fakes` only; they never depend on `:data`. Each feature agent adds its fake scenarios in `android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/` and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from `:core:design` (D-019). 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.
* **Contract requests for a later contract pass (M12e)**, collected from the M5, M6 and M7 reports; none is a contract change in v0.11, each needs the §14.8 rule when taken:
* **Contract requests for a later contract pass (M12e)**, collected from the M5, M6, M7 and M8 reports; none is a contract change in v0.12, each needs the §14.8 rule when taken (M12e started 2026-10-18):
* the `pair()` result lacks the invite kind, so S-2 shows for any first `member` pairing, device invites included (AC-2.4, AC-2.10);
* the `kvitt://invite` link carries no admin name, so `onboarding_code_help` ("You get the code from %1$s …") cannot be filled and S-1 shows the generic help line (AC-2.6);
* `SyncSummary` has no "last attempt failed" flag; `SyncStatusPill` is called with `failed = false` (§13.3 S-4);
@@ -1195,16 +1195,22 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
* `UpdateRepository.check()` has no result, so S-22 cannot tell "up to date" from a network failure (AC-16.6, D-073);
* `SettleState` lacks the list of accounts the settlement leaves untouched; S-9 derives it from Home balances, which include the settlement being edited (D-074);
* the D-027 re-stamp (re-stamp `updated_at` of pending rows after a `clock_skew` rejection) is still not in `:data`;
* the 128 px picture size: `:data` always fetches E-11 at 512; a 128 variant needs a size parameter in `:core:model` and `:data` (§14.2.4 "Pictures"); the owner answers whether it is needed.
* the 128 px picture size: `:data` always fetches E-11 at 512; a 128 variant needs a size parameter in `:core:model` and `:data` (§14.2.4 "Pictures"); the owner answers whether it is needed;
* `PushEvent` lives in `:adapter:fcm` as built by M8; §6.2 places it in `:core:model`, where it should move (M8);
* S-22's "Licenser för öppen källkod" does nothing: no licences screen and no strings for one exist (S-22 and the §6.2 strings rule); open point OQ-5 for the owner, default "not in v1" (M8);
* the detail screens (`AccountDetailScreen`, `ExpenseDetailScreen`, `SettlementDetailScreen` and others) take no `snackbarHostState`, so `:app` draws an overlay snackbar host that can overlap a FAB or the bottom bar (M8);
* S-7 and S-8 hand `:app` resolved snackbar `String`s instead of resource ids (M8);
* `:app` has no launcher icon (lint `MissingApplicationIcon`, present before M8 too) (M8);
* the server sends the Firebase Installation ID in the FCM HTTP v1 `message.token` field, accepted during Firebase's transition; it should send it in the `fid` field (§14.6, D-083).
* **M2c-dev / M2c-pi** (D-001): M2c-dev builds only what development needs on the Mac and runs now; M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) and runs after M8, before M10.
* **M4** (landed `bd6f10a`) added `into_make_service_with_connect_info::<SocketAddr>()` 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** is the only plan that edits `:app` after M2b. In v0.10 it was given M6's contract requests (see M6 above), the D-027 re-stamp and the 128 px picture size question (§14.2.4 "Pictures"); it keeps the `:app` ones (`LocalAvatarPictures`, `ProvideHousehold`, `LocalKvittClock`, the One UI font check). Since v0.11 the items that need `:core:model` or `:data` (the `SyncSummary` "last attempt failed" field, the D-027 re-stamp, the picture size) are on the M12e list below; M8 wires the navigation of §13.2 (D-071) and deletes the debug fakes module (§6.2). M5's contract requests (v0.5): `:app` provides `AppBuildInfo(versionCode, versionName, debug)` and includes `DataBindingsModule` in the release graph (D-041); WorkManager is configured with `KvittWorkerFactory` and the default initializer disabled; `:app` calls `SyncTriggers.onAppStart`, `onForeground` and `syncForPush`, and `UpdateRepository.check` on cold start and every resume; the `ImageLoader` that `:data` provides is set as Coil's singleton. It owns the `:app` items network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), and copies `google-services.json` from `infra/secrets/` with `infra/dev/copy-google-services.sh` (§10.7).
* **M8** (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). Not yet done at landing (phone not attached): the end-to-end pairing, offline and push test on the owner's phone and the design-spec §2.7 typeface check (HANDOFF §3). Its contract requests are on the M12e list above.
* **M10**: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 (D-024).
* **M2c-pi**: 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 (D-023).
* **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). 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.
* **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). 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; still pending after M8 landed (phone not attached).
* **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.
---
@@ -1219,6 +1225,7 @@ 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 | `TODO(spec)` LAN address and SSH user 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. | M2c-pi | none; M2c-pi blocks on it |
| OQ-4 | Answered: the Google Drive backup folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (§10.9). | M2c-pi | answered |
| OQ-5 | Open-source licences on S-22: is a licences screen wanted in v1? "Licenser för öppen källkod" currently does nothing, and no screen or strings exist for it (§11.3 M12e list). | M12e or M10 | not in v1 |
---
@@ -1244,8 +1251,9 @@ A brief for one UX agent (Opus) who has read this spec and nothing else. It runs
* A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail.
* Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back.
* The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room).
* Wiring (D-071, as built by M7a to M7f; M8 connects it): 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, and the `NavHost` switches top-level destinations from those lambdas. Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID` and `ARG_ACCOUNT_ID`; `SettleArgs.SETTLEMENT_ID` and `SettleArgs.ACCOUNT_ID`; `DrillDownArgs.KEYS`; settings `"categoryId"` and `"templateId"`), read through `SavedStateHandle`.
* Wiring (D-071, as built by M7a to M7f and connected by M8): 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, and the `NavHost` switches top-level destinations from those lambdas. Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID` and `ARG_ACCOUNT_ID`; `SettleArgs.SETTLEMENT_ID` and `SettleArgs.ACCOUNT_ID`; `DrillDownArgs.KEYS`; settings `"categoryId"` and `"templateId"`), read through `SavedStateHandle`.
* Result snackbars (D-071): the result of S-7 ("Utgiften sparades"), of S-9 and of archiving an account is shown by the screen returned to (Home shows them through its `snackbarHostState`); the account-archived snackbar has an undo action (`common_undo`, "Ångra").
* Session gate (as built by M8): S-1 for an unpaired or unrecognised session, S-2 for the profile step after a first `member` pairing (AC-2.10; the invite-kind request of §11.3), S-18 for a revoked device (back on S-18 closes the app). Home shows S-3 while the household has no partner (`partner == null`).
### 13.3 Screens and what each must show
@@ -1274,7 +1282,7 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
| 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) plus day of month (a new template defaults to today's day, AC-9.8, D-072), first month, optional last month, pause switch; the discard dialog has no body text (D-080) | Add; Edit; Pause/Resume; Delete |
| S-20 | Devices | Rows per AC-3.1 | 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`), open-source licences; after "Check for update" with no update, "Du har den senaste versionen" (AC-16.6, D-073) | Check for update |
| S-22 | About | App version name and code, server URL, server version (from `/health`), open-source licences (not built; OQ-5); after "Check for update" with no update, "Du har den senaste versionen" (AC-16.6, D-073) | Check for update |
System notifications (copy needed in both languages, built per AC-13.5):
@@ -2540,6 +2548,8 @@ FCM HTTP v1 message the server sends, one per target device (`POST https://fcm.g
}
```
`message.token` holds the device's `fcm_token`, which is the Firebase Installation ID the phone registered (D-083); FCM HTTP v1 accepts an installation id in `token` during Firebase's transition, so the server needs no change. Sending it in the `fid` field instead is a follow-up request (§11.3 M12e list).
Data-only messages (no `notification` block), all values strings. Types (exhaustive):
| `type` | `entity_type` | `entity_id` | `actor_user_id` | `count` | Sent to |
@@ -2561,11 +2571,11 @@ Server sending rules:
* FCM `UNREGISTERED` or HTTP 404 for a token clears that device's `fcm_token`, only if the stored token is still the one that was sent (a token the phone replaced meanwhile is kept).
* Push is disabled (`NoopPushQueue`, one start-up warning) when the credentials file is missing or unusable (treated alike).
Phone handling (`:adapter:fcm` maps the message to `PushEvent(type, entityType, entityId, actorUserId, count)` in `:core:model`; `:app` handles it):
Phone handling (`:adapter:fcm` maps the message to `PushEvent(type, entityType, entityId, actorUserId, count)`, a `:core:model` type that as built still lives in `:adapter:fcm`, §11.3; `:app` handles it in an expedited WorkManager job, D-085):
1. Enqueue an expedited sync and wait for it up to 20 seconds.
2. Build the notification from local data per §13.3 copy (or the generic text if the sync failed or the entity is missing). Channel id `activity`, name "Activity" / "Aktivitet", importance default. Notification id = hash of `entity_id` (so an update replaces the create's notification); `sync_batch` uses a fixed id.
3. Tap opens S-8 or S-10 for the entity, Home for deletions and `sync_batch`.
1. Call `syncForPush` and wait for it up to 20 seconds.
2. Build the notification text from Room per §13.3 copy (or the generic text if the sync failed or the entity or its actor is missing locally). Channel id `activity`, name "Activity" / "Aktivitet", importance default. Notification id = hash of `entity_id` (so an update replaces the create's notification); `sync_batch` uses id `1`.
3. Tap opens S-8 or S-10 for the entity; Home for deletions, `sync_batch` and the generic notification (D-085).
4. Unknown `type` or `v` other than `1`: sync only, no notification.
### 14.7 Module layout and ownership map
@@ -2685,3 +2695,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 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 "<range> · Hushållet" / "<range> · 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 "<name>, <range caption>", 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. |