SPEC v0.13: M12e outcomes (D-086 to D-095), server schema 3, 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:
@@ -1,6 +1,6 @@
|
||||
# Kvitt: specification
|
||||
|
||||
Version **0.12** (2026-10-18). Schema version **2**. Sync protocol version **1**. Push payload version **1**.
|
||||
Version **0.13** (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.
|
||||
|
||||
@@ -118,19 +118,19 @@ The server starts with an empty database. The household is created from the comm
|
||||
* AC-2.1 The admin creates a `partner` invite from Settings. It is refused with `household_full` when two users exist. Creating a new `partner` invite cancels any pending one.
|
||||
* AC-2.2 The admin creates a `device` invite for either user from Settings (to pair a second phone).
|
||||
* 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. 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.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/<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>` (followed by `&from=<urlencoded name>` 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/<CODE>[?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.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.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.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.
|
||||
* 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).
|
||||
|
||||
### FR-3 Devices and revocation
|
||||
|
||||
* AC-3.1 Settings lists devices: the admin sees all devices of both users; a member sees their own. Each row shows device name, owner, paired date, last seen date, app version, and "This device" for the current one.
|
||||
* AC-3.1 Settings lists devices: the admin sees all devices of both users; a member sees their own. Each row shows device name, owner, paired date, last seen date, app version (the version name, "Kvitt 1.1", once the device has reported one, else the version code; D-090), and "This device" for the current one.
|
||||
* AC-3.2 The admin can revoke any device except the one in use (`cannot_revoke_current_device`). A member can revoke their own devices except the one in use.
|
||||
* AC-3.3 A revoked device's next request gets HTTP 401 `device_revoked`. The app then deletes its token, its local database and its cached pictures, and shows the "Device removed" screen with a "Pair again" button.
|
||||
* AC-3.4 A revoked device never receives pushes again.
|
||||
@@ -282,7 +282,7 @@ The server starts with an empty database. The household is created from the comm
|
||||
* AC-16.3 "Update" downloads the APK to the app's cache, verifies the size and SHA-256 from the metadata, and installs through `PackageInstaller`. The first time, Android asks the user to allow "Install unknown apps" for Kvitt; the app explains this beforehand: `UpdateRepository` emits `UpdateState.NeedsInstallPermission` once (the explanation-shown flag in `prefs.preferences_pb`, §14.2.2), and the next `install()` proceeds to the system prompt.
|
||||
* AC-16.4 A verification failure deletes the file and shows "Download failed, try again".
|
||||
* AC-16.5 Debug builds never check for updates.
|
||||
* AC-16.6 "Check for update" on S-22 calls `UpdateRepository.check()`; when `UpdateState` is still `None` afterwards it reports "You have the latest version" (`settings_about_up_to_date`, "Du har den senaste versionen"). A network failure cannot be told apart because `check()` returns nothing; accepted for v1, with a contract request in §11.3 (D-073).
|
||||
* AC-16.6 "Check for update" on S-22 calls `UpdateRepository.check()`, which returns an `UpdateCheck` (D-092, revising D-073): `UpToDate` (nothing newer published; a 404 from E-5 counts as up to date) reports "You have the latest version" (`settings_about_up_to_date`, "Du har den senaste versionen"); `Failed` (no answer or another error) reports "Could not reach the server" (`common_could_not_reach_server`); `Available` shows the update banner. Debug builds report `UpToDate` without a request (AC-16.5).
|
||||
|
||||
### FR-17 Splitwise import
|
||||
|
||||
@@ -412,6 +412,7 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2).
|
||||
| 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`) |
|
||||
| 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 |
|
||||
|
||||
Module graph (directory `android/`):
|
||||
|
||||
@@ -422,7 +423,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`, 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` |
|
||||
| `: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` |
|
||||
| `: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` |
|
||||
@@ -552,7 +553,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in
|
||||
* The server stores only `token_hash` = lowercase hex SHA-256 of the full token string.
|
||||
* Tokens do not expire. They stop working only on revocation.
|
||||
* Every authenticated request sends `Authorization: Bearer <token>`. The server hashes it, looks up a device with that hash and `revoked_at IS NULL`, and loads the user. Missing or unknown token: 401 `unauthorized`. Revoked: 401 `device_revoked`.
|
||||
* The server updates `devices.last_seen_at` and `devices.app_version_code` at most once per hour per device. That update sets `app_version_code` only when the request carries `X-Kvitt-App-Version` and its value parses as an integer; otherwise the stored value is kept.
|
||||
* The server updates `devices.last_seen_at`, `devices.app_version_code` and `devices.app_version_name` at most once per hour per device. That update sets `app_version_code` only when the request carries `X-Kvitt-App-Version` and its value parses as an integer, and `app_version_name` only when the request carries `X-Kvitt-App-Version-Name` with 1 to 32 characters after trimming (D-090); otherwise the stored value is kept. E-3 stores both from the accepting request.
|
||||
* On the phone the token is encrypted with an Android Keystore AES-256-GCM key (alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, no export) and the ciphertext and IV are stored in DataStore file `auth.preferences_pb`. The server base URL, user id and device id are stored next to it in plain text, together with the pairing status (D-043).
|
||||
* The token is never logged, never put in a URL, never included in a crash report or screenshot.
|
||||
|
||||
@@ -858,7 +859,7 @@ 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 file stays `infra/env.example`, written by M2c-pi. The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup. 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 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 file stays `infra/env.example`, written by M2c-pi. 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.
|
||||
|
||||
### 10.1 Hosts and network
|
||||
|
||||
@@ -1036,7 +1037,7 @@ kvitt-import --db /data/kvitt.sqlite --file <csv> --account "<Kvitt account name
|
||||
|
||||
* One CSV file per Splitwise group (Splitwise's group "Export as spreadsheet"). The account is created with default 50/50 if no non-deleted account with that name exists; otherwise rows go into the existing one, and the existing account's stored name (not the spelling given to `--account`) is the account name used in the import key (D-046).
|
||||
* `--user` (exactly two, distinct Splitwise names, one per household user) maps a member column to a Kvitt user given as a user id or as a display name that exactly one user has; anything else is a usage error (D-046).
|
||||
* The importer never creates or migrates a database: `--db` must name an existing file whose `meta.schema_version` is the version the server's migrations produce (`2` since D-054), else it exits 1 (run `kvitt-server migrate` first) (D-046, D-054).
|
||||
* The importer never creates or migrates a database: `--db` must name an existing file whose `meta.schema_version` is the version the server's migrations produce (`3` since D-090; `2` from D-054), else it exits 1 (run `kvitt-server migrate` first) (D-046, D-054).
|
||||
* The whole run is one `BEGIN IMMEDIATE` transaction: find or create the account, skip rows whose `import_key` exists, validate and insert the rest with the server's own push checks, write the audit entries, then reconcile (below). It commits only when every row is valid, the reconciliation is accepted and `--dry-run` is absent; otherwise it rolls back and nothing is written. A dry run runs the same transaction and rolls it back (D-046).
|
||||
* Exit status (D-046): `0` imported (or, with `--dry-run`, would import); `1` database or file error; `2` usage (bad arguments, `--user` not resolving to the two household users); `3` the file cannot be imported (header, invalid rows, non-SEK rows without `--fx`); `4` reconciliation mismatch or missing totals row. Nothing is written unless the status is `0` and `--dry-run` is absent.
|
||||
* Header (confirmed against both real exports, D-003; the exact header lines are recorded in PLAN-M9, not here): Splitwise localises the export, so two variants exist and the importer must accept both. English: `Date`, `Description`, `Category`, `Cost`, `Currency`; Swedish: `Datum`, `Beskrivning`, `Kategori`, `Kostnad`, `Valuta`. Then one column per group member, named by the member's full Splitwise name, holding that member's net effect for the row (positive = paid more than their share). The member columns appear in different orders in different files, so the importer reads member names from the header and matches them to the `--user` arguments; it never relies on column positions. Any other header fails the import with a clear message, writing nothing.
|
||||
@@ -1140,9 +1141,12 @@ M12d UI contract additions (D-059: frequentCategoryIds, deletable; landed bdba9d
|
||||
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)
|
||||
|
||||
After M8 (v0.13):
|
||||
M8, M7g (landed) ─► M12e contract pass (landed 1470a2f and 90505a8; D-086 to D-095, server schema 3)
|
||||
```
|
||||
|
||||
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.
|
||||
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 (D-086 to D-095) ran after them and landed on 2026-10-18 (`1470a2f` contract pass, `90505a8` launcher icon).
|
||||
|
||||
### 11.2 Milestone table
|
||||
|
||||
@@ -1173,6 +1177,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
| 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 (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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -1185,32 +1190,19 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
* **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 (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, 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);
|
||||
* `Rejection` has no actor, so the stale-change copy on S-16 names the partner;
|
||||
* `Device` has only `appVersionCode`, so S-20 rows show "Kvitt 11" where the design shows the version name "1.1";
|
||||
* `RecurringRepository.create` needs a `TemplateId` for a new template; the S-19 editor sends `TemplateId("")`;
|
||||
* `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;
|
||||
* `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);
|
||||
* **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's yes/no stays open, HANDOFF §3). 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.13; each needs the §14.8 rule when taken):
|
||||
* 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";
|
||||
* 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** (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.
|
||||
* **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 were taken by M12e (D-086 to D-095), except the `fid` field (remaining contract requests 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; still pending after M8 landed (phone not attached).
|
||||
* **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; 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.
|
||||
|
||||
---
|
||||
@@ -1225,7 +1217,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 |
|
||||
| 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 remaining contract requests). | M10 | not in v1 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1252,8 +1244,8 @@ A brief for one UX agent (Opus) who has read this spec and nothing else. It runs
|
||||
* 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 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`).
|
||||
* 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; the account-archived snackbar has an undo action (`common_undo`, "Ångra"). As built by M12e (D-095): six screens host `:app`'s shared `snackbarHostState` in their own scaffold (Home, AccountDetail, ExpenseDetail, SettlementDetail, DrillDown, SyncStatus), so a snackbar never overlaps a FAB or the bottom bar; `:app` has no overlay host. Features hand `:app` string resource ids, never resolved strings: `ExpenseEditorScreen.onSaved(ExpenseId, @StringRes Int?)`, and `onDeleted()` on S-7 and S-8 takes no argument, `:app` then shows `snackbar_expense_deleted`.
|
||||
* Session gate (as built by M8): S-1 for an unpaired or unrecognised session, S-2 for the profile step after a `partner` pairing only (AC-2.10, D-086; a member pairing a second phone with a `device` invite goes to Home), 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
|
||||
|
||||
@@ -1261,28 +1253,28 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
|
||||
|
||||
| Id | Screen | Must show | Actions |
|
||||
|---|---|---|---|
|
||||
| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); invite code field (12 characters, shown in groups of 4); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from (always the generic help line in v1: the invite link carries no admin name for `onboarding_code_help`, §11.3); progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code |
|
||||
| S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown after any first `member` pairing until `pair()` reports the invite kind (§11.3) | Continue |
|
||||
| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); invite code field (12 characters, shown in groups of 4); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from (`onboarding_code_help` with the admin's name when the invite link carries `from`, else the generic help line `onboarding_code_help_generic`; `PairingScreen(initialAdminName)` and `PairingViewModel.prefill` carry the name from `:app`'s `AppLink.Invite`, and a pasted link fills it too, D-087); progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code |
|
||||
| S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a `partner` pairing (`Pairing.inviteKind`, D-086) | Continue |
|
||||
| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") | Invite partner (goes to S-21); Settings |
|
||||
| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 (`SyncStatusPill` takes a `failed` flag for "Sync failed" until `SyncSummary` gets a "last attempt failed" field; Home passes `failed = false`, a contract request for M12e, §11.3); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account |
|
||||
| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when `SyncSummary.lastAttemptFailed`; `SyncStatusPill` takes no `failed` parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account |
|
||||
| S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed) |
|
||||
| S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state; the discard dialog has a title and no body text (D-080) | Save; Cancel |
|
||||
| S-7 | Expense editor | Amount entry (large, numeric keyboard, `kr`); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); split section: mode chooser (`default` "Account default 60/40", `ratio` "Custom ratio", `exact` "Exact amounts", `full` "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode; after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071); the quick row is filled by `quickRowCategories` (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) | Save; Cancel; Delete (edit mode) |
|
||||
| S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: <template>" when generated; timeline (AC-12.4, AC-12.5); "deleted" state when opened from a notification of a deletion; "missing" state (not yet on this phone) with "Synka nu" (`expense_detail_sync_now`), online only (§9.9, D-080) | Edit; Delete |
|
||||
| S-9 | Settle up | Direction ("Anna pays Jacob" with a swap control); amount; allocation list: account name, account debt, editable allocated amount, over-settle warning per row (AC-11.5); remaining-to-allocate indicator; "Add account"; date; note; per-account variant with a single fixed row, which keeps the direction swap (§9.4: per-account settle-ups go either way) and an editable amount (AC-11.4 "defaults to") (D-052); when editing, the saved rows count as edited and "Föreslå igen" re-proposes; the info line on untouched accounts is derived from Home balances and may be slightly off while editing (D-074); the discard dialog has no body text (D-080) | Confirm; Cancel |
|
||||
| S-7 | Expense editor | Amount entry (large, numeric keyboard, `kr`); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); split section: mode chooser (`default` "Account default 60/40", `ratio` "Custom ratio", `exact` "Exact amounts", `full` "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode; after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071), `onSaved` handing `:app` the string resource id (D-095); the quick row is filled by `quickRowCategories` (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) | Save; Cancel; Delete (edit mode) |
|
||||
| S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: <template>" when generated; timeline (AC-12.4, AC-12.5); "deleted" state when opened from a notification of a deletion; "missing" state (not yet on this phone) with "Synka nu" (`expense_detail_sync_now`), online only (§9.9, D-080); after a delete `:app` shows `snackbar_expense_deleted` on the screen returned to (D-095) | Edit; Delete |
|
||||
| S-9 | Settle up | Direction ("Anna pays Jacob" with a swap control); amount; allocation list: account name, account debt, editable allocated amount, over-settle warning per row (AC-11.5); remaining-to-allocate indicator; "Add account"; date; note; per-account variant with a single fixed row, which keeps the direction swap (§9.4: per-account settle-ups go either way) and an editable amount (AC-11.4 "defaults to") (D-052); when editing, the saved rows count as edited and "Föreslå igen" re-proposes; the info line on untouched accounts reads `SettleState.untouched` (nets without the settlement being edited, empty for a per-account settle-up, D-093, revising D-074's reading of Home balances); the discard dialog has no body text (D-080) | Confirm; Cancel |
|
||||
| S-10 | Settlement detail | Payer, recipient, amount, date, note, allocations per account; timeline; "missing" state with "Synka nu" (`settle_detail_sync_now`), online only (§9.9, D-080) | Edit; Delete |
|
||||
| S-11 | Analytics | Range selector (presets of AC-14.2 plus custom); granularity toggle month/week; measure toggle household / my share; group-by toggle category/account; total for range; chart 1 stacked bars over time with legend; chart 2 category breakdown; chart 3 account breakdown; empty state ("No expenses in this period"); a chart whose range has no expenses shows only the 0 baseline, with no gridline values (D-068). Total card (D-069): caption "<range> · Hushållet" for the household measure and "<range> · 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 (§9.8, D-077). Legend and stacking order: categories by `sortOrder`, accounts in `AccountRepository.all()` order (`AnalyticsState.groups` order is unspecified, §14.2.4). Loading means `SyncSummary.lastSuccessAt == null` and shows skeleton cards; offline looks like loaded; no error state (analytics are local, AC-14.1) | Change filters; tap a bar or a row to open S-12 |
|
||||
| S-12 | Analytics drill-down | Title naming the filter ("Mat & hushåll, oktober 2026"; a week as in `docs/ux/strings.csv`, "Vardag, vecka 41"; a category or account over the whole range "<name>, <range caption>") (D-070); total; expense rows as in S-5. No loading and no error state (D-070) | Open expense |
|
||||
| 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 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) | 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) 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-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; 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`), 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 |
|
||||
| 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": `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):
|
||||
|
||||
@@ -1345,7 +1337,7 @@ Contracts are what lets agents work in parallel. Each subsection is binding. The
|
||||
|
||||
### 14.1 SQLite schema v1 (server) and migrations
|
||||
|
||||
The server schema is at version **2**: the v1 DDL below (migration 0001) plus migration 0002 (§14.1.1, D-054). This DDL is the content of `contracts/schema/server-v1.sql` and of the server's first migration `server/crates/kvitt-server/migrations/0001_init.sql`, byte for byte (a test asserts equality). Conventions: ids `TEXT` (UUIDv7, see the conventions at the top of this file); money `INTEGER` öre; timestamps `TEXT` in the fixed RFC 3339 form; booleans `INTEGER` 0/1; `server_version` from `sync_sequence`. Enum and token lists that may grow (icons, colours) are checked in code, not in DDL. Every connection sets the pragmas of §8.9.
|
||||
The server schema is at version **3**: the v1 DDL below (migration 0001) plus migration 0002 (§14.1.1, D-054) and migration 0003 (§14.1.2, D-090). This DDL is the content of `contracts/schema/server-v1.sql` and of the server's first migration `server/crates/kvitt-server/migrations/0001_init.sql`, byte for byte (a test asserts equality). Conventions: ids `TEXT` (UUIDv7, see the conventions at the top of this file); money `INTEGER` öre; timestamps `TEXT` in the fixed RFC 3339 form; booleans `INTEGER` 0/1; `server_version` from `sync_sequence`. Enum and token lists that may grow (icons, colours) are checked in code, not in DDL. Every connection sets the pragmas of §8.9.
|
||||
|
||||
```sql
|
||||
-- Kvitt server schema v1. Do not edit; add a new migration and bump the schema version (SPEC §14.8).
|
||||
@@ -1665,6 +1657,26 @@ INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_
|
||||
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).
|
||||
|
||||
* 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.
|
||||
* The Room schema is unchanged (version 1): the phone stores no devices (§14.2).
|
||||
|
||||
```sql
|
||||
-- Kvitt server schema v3: the app's versionName per device (devices.app_version_name), sent in the
|
||||
-- X-Kvitt-App-Version-Name header and written like app_version_code (SPEC §14.3.1, §8.2). Do not edit; add a new
|
||||
-- migration and bump the schema version (SPEC §14.8). Applied on top of 0002_categories_v2.sql.
|
||||
--
|
||||
-- Existing devices keep every column and get NULL until their next request that carries the header.
|
||||
ALTER TABLE devices ADD COLUMN app_version_name TEXT
|
||||
CHECK (app_version_name IS NULL OR length(app_version_name) BETWEEN 1 AND 32);
|
||||
|
||||
UPDATE meta SET value = '3' WHERE key = 'schema_version';
|
||||
```
|
||||
|
||||
### 14.2 Room entities (phone)
|
||||
|
||||
Database class `KvittDatabase` in `:data`, file name `kvitt.db`, version **1**, `exportSchema = true` with schemas in `android/data/schemas/` (committed). Package `se.hagstedt.kvitt.data.db`. Room 3 (`androidx.room3`, KSP only) on an `SQLiteDriver` with `sqlite-bundled` (D-012). M2b created the entities and `KvittDatabase` version 1; the exported `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` is a contract file (§14.8).
|
||||
@@ -1762,6 +1774,7 @@ sealed interface SaveResult<out T> {
|
||||
data class FieldError(val field: String, val error: String) // error = a domain error token of §14.5.3, "required", or "validation_failed" for a §9.6 rule without a domain token
|
||||
|
||||
// Session and onboarding (S-1, S-2, S-3, S-18)
|
||||
data class Pairing(val user: User, val inviteKind: InviteKind) // v0.13, D-086: the E-3 invite_kind; an unknown wire value reads as DEVICE
|
||||
sealed interface SessionState {
|
||||
data object Unpaired : SessionState
|
||||
data class Paired(val me: User, val partner: User?, val deviceId: DeviceId) : SessionState
|
||||
@@ -1770,7 +1783,7 @@ sealed interface SessionState {
|
||||
}
|
||||
interface SessionRepository {
|
||||
val session: Flow<SessionState>
|
||||
suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User>
|
||||
suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<Pairing> // v0.13, D-086
|
||||
suspend fun acknowledgeRevoked() // wipes local data (AC-3.3) and moves to Unpaired
|
||||
}
|
||||
|
||||
@@ -1779,7 +1792,7 @@ data class BalanceView(val netOre: Long, val partner: User)
|
||||
|
||||
// Home (S-4)
|
||||
data class AccountSummary(val account: Account, val netOre: Long, val expensesThisMonth: Int)
|
||||
data class SyncSummary(val lastSuccessAt: String?, val pendingCount: Int, val rejectedCount: Int, val clockSkew: Boolean, val running: Boolean)
|
||||
data class SyncSummary(val lastSuccessAt: String?, val pendingCount: Int, val rejectedCount: Int, val clockSkew: Boolean, val running: Boolean, val lastAttemptFailed: Boolean) // lastAttemptFailed: v0.13, D-088
|
||||
data class HomeState(val me: User, val partner: User?, val global: BalanceView?, val active: List<AccountSummary>, val archived: List<AccountSummary>, val sync: SyncSummary, val update: UpdateInfo?)
|
||||
interface HomeRepository { fun home(): Flow<HomeState> }
|
||||
|
||||
@@ -1825,7 +1838,8 @@ interface ExpenseRepository {
|
||||
|
||||
// Settlements (S-9, S-10)
|
||||
data class AllocationLine(val account: Account, val payerNetOre: Long, val amountOre: Long, val overSettles: Boolean)
|
||||
data class SettleState(val payer: User, val recipient: User, val amountOre: Long?, val lines: List<AllocationLine>, val remainingOre: Long, val canConfirm: Boolean, val date: String, val note: String)
|
||||
data class UntouchedAccount(val account: Account, val payerNetOre: Long) // v0.13, D-093
|
||||
data class SettleState(val payer: User, val recipient: User, val amountOre: Long?, val lines: List<AllocationLine>, val remainingOre: Long, val canConfirm: Boolean, val date: String, val note: String, val untouched: List<UntouchedAccount>) // untouched: v0.13, D-093
|
||||
data class SettleRequest(val editing: SettlementId?, val payer: UserId, val amountOre: Long?, val fixedAccount: AccountId?, val allocations: Map<AccountId, Long>?, 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<Pair<Account, Long>>, val deleted: Boolean, val pending: Boolean, val timeline: List<TimelineLine>)
|
||||
interface SettlementRepository {
|
||||
@@ -1864,13 +1878,14 @@ interface CategoryRepository {
|
||||
fun deletable(id: CategoryId): Flow<Boolean> // v0.9, D-059: false for a default category or one used by a non-deleted expense or non-deleted recurring template (AC-7.6)
|
||||
}
|
||||
data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?)
|
||||
data class NewRecurringTemplate(val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean) // v0.13, D-091
|
||||
interface RecurringRepository {
|
||||
fun all(): Flow<List<RecurringTemplate>>
|
||||
suspend fun create(t: RecurringTemplate): OnlineResult<TemplateId>
|
||||
suspend fun create(t: NewRecurringTemplate): OnlineResult<TemplateId> // v0.13, D-091: the server mints the id
|
||||
suspend fun update(t: RecurringTemplate): OnlineResult<Unit>
|
||||
suspend fun delete(id: TemplateId): OnlineResult<Unit>
|
||||
}
|
||||
data class Device(val id: DeviceId, val user: User, val name: String, val pairedAt: String, val lastSeenAt: String, val appVersionCode: Int?, val current: Boolean, val revoked: Boolean)
|
||||
data class Device(val id: DeviceId, val user: User, val name: String, val pairedAt: String, val lastSeenAt: String, val appVersionCode: Int?, val appVersionName: String?, val current: Boolean, val revoked: Boolean) // appVersionName: v0.13, D-090
|
||||
interface DeviceRepository {
|
||||
suspend fun list(): OnlineResult<List<Device>>
|
||||
suspend fun revoke(id: DeviceId): OnlineResult<Unit>
|
||||
@@ -1881,7 +1896,7 @@ interface InviteRepository {
|
||||
suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite>
|
||||
suspend fun cancel(id: InviteId): OnlineResult<Unit>
|
||||
}
|
||||
data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String)
|
||||
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 SyncStatusState(val summary: SyncSummary, val pending: List<LedgerRow>, val rejections: List<Rejection>)
|
||||
interface SyncRepository {
|
||||
fun status(): Flow<SyncStatusState>
|
||||
@@ -1896,14 +1911,22 @@ sealed interface UpdateState {
|
||||
data class Failed(val info: UpdateInfo) : UpdateState
|
||||
data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState
|
||||
}
|
||||
sealed interface UpdateCheck { // v0.13, D-092
|
||||
data object UpToDate : UpdateCheck
|
||||
data class Available(val info: UpdateInfo) : UpdateCheck
|
||||
data object Failed : UpdateCheck
|
||||
}
|
||||
interface UpdateRepository {
|
||||
val state: Flow<UpdateState>
|
||||
suspend fun check()
|
||||
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?)
|
||||
interface AboutRepository { fun about(): Flow<AboutState> }
|
||||
interface ConnectivityRepository { val online: Flow<Boolean> } // §9.9
|
||||
|
||||
// Push (§14.6), v0.13, D-095: model/Push.kt; :adapter:fcm maps FCM data messages into it
|
||||
data class PushEvent(val type: String, val entityType: String, val entityId: String, val actorUserId: String, val count: Int)
|
||||
```
|
||||
|
||||
Agents may add fields to these types only through a contract change (§14.8). A feature that needs a value not listed asks the orchestrator.
|
||||
@@ -1939,9 +1962,22 @@ Readings fixed in v0.11 (D-069, D-073 to D-075, as landed by M7a to M7f and M6b;
|
||||
|
||||
* **`AnalyticsState.groups` order (D-069).** Unspecified; the UI does not rely on it. S-11 sorts legends and stacks itself: categories by `sortOrder`, accounts in `AccountRepository.all()` order.
|
||||
* **`FieldError.field` values (D-074).** The model property names: `amountOre`, `description`, `notes`, `date`, `split`, `accountId`, `categoryId`, `allocations`, as `:core:fakes` and `:data` emit them.
|
||||
* **`UpdateRepository.check()` (D-073).** Returns nothing; S-22 reads `UpdateState` afterwards and treats a still-`None` state as "up to date". A result type is a contract request (§11.3).
|
||||
* **`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`):
|
||||
|
||||
* **`Pairing` (D-086).** `SessionRepository.pair` returns `OnlineResult<Pairing(user, inviteKind)>`, `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.
|
||||
* **`SyncSummary.lastAttemptFailed` (D-088).** True when the last finished cycle started (`sync_state.last_attempt_at`) after the last success (`last_success_at`), or none ever succeeded, and no cycle is running; false while one runs. Computed in `:data`'s `SyncSummaries` (the two fixed-form timestamps compared as text); fakes take `FakeScenario.lastAttemptFailed`. `SyncStatusPill`, `syncPillVariant` and `syncLineText` dropped their `failed` parameter; S-16 shows "failed" when online and `lastAttemptFailed`.
|
||||
* **`Rejection.actor` (D-089).** The user named by `updated_by_user_id` of the server's `current` row stored with the rejection (`sync_rejections.server_current`, §14.4.3), resolved to a household user; null when the server had no row or the user is unknown, and S-16's stale copy then names the partner. No protocol or Room change.
|
||||
* **`Device.appVersionName` (D-090).** From the Device wire object's `app_version_name` (E-7, E-15, E-3); null until the device has sent `X-Kvitt-App-Version-Name`. `:data` sends the header on every call, the Coil picture requests included, with the `BuildConfig` `versionName`, omitted when it is blank, longer than 32 characters or not printable ASCII.
|
||||
* **`NewRecurringTemplate` (D-091).** `RecurringRepository.create` takes the template fields without an id (E-28's body never had one); the server mints the id, which `create` returns; `update` keeps taking a `RecurringTemplate`.
|
||||
* **`UpdateCheck` (D-092, revising D-073).** `check()` returns `UpToDate` (nothing newer published, a 404 from E-5 included, or within 6 hours of the last check that reached the server with nothing newer known), `Available(info)` or `Failed` (no answer or another error; `state` unchanged, and after no answer the next call tries again at once). Debug builds return `UpToDate` without a request (AC-16.5).
|
||||
* **`SettleState.untouched` (D-093).** The accounts not in `lines` where the payer is owed (`payerNetOre > 0`), with nets that exclude the settlement being edited (AC-11.6), in `AccountRepository.all()` order; empty for a per-account settle-up. Computed in `:data` and in `:core:fakes` alike. Revises D-074's reading of Home balances.
|
||||
* **`PushEvent` (D-095).** Lives in `:core:model` (`model/Push.kt`) as the project's own record; `:adapter:fcm` keeps `PushListener` and `PushMapping`, which maps the FCM data message into it.
|
||||
* **Pictures** (v0.5 reading below) unchanged by assumption: no size parameter, E-11 always at 512.
|
||||
|
||||
Readings fixed in v0.5 (no type changes):
|
||||
|
||||
* **`OnlineResult.Failed("network", null)`.** A REST call that fails on the network (no answer from the server) returns code `network`, the one token outside §14.3.2, so the UI can show "Could not reach the server" (§9.9) rather than the `Offline` state, which means the call was not attempted (D-029).
|
||||
@@ -1956,7 +1992,7 @@ Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt
|
||||
#### 14.3.1 Common rules
|
||||
|
||||
* Content type `application/json; charset=utf-8` for every request and response body, except: `PUT /me/picture` (request `image/jpeg`, `image/png` or `image/webp`, raw bytes), `GET /users/{user_id}/picture` (response `image/jpeg`), `GET /i/{code}` (response `text/html; charset=utf-8`), `GET /apk/files/{file_name}` (response `application/vnd.android.package-archive`).
|
||||
* Request headers the app sends on every call: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version: <versionCode>`, `Accept-Language: <device locale>`, and `Authorization: Bearer <token>` on authenticated endpoints. A request to an authenticated endpoint without `X-Kvitt-Protocol` or with a value other than `1` gets 426 `protocol_unsupported` (the app then stops syncing until its installed version code changes, and shows the update banner when a newer APK is published, §14.2.4). The protocol check comes before the token check.
|
||||
* Request headers the app sends on every call: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version: <versionCode>`, `X-Kvitt-App-Version-Name: <versionName>` (optional for the server; stored per device at E-3 and on the hourly last-seen update, §8.2; blank or longer than 32 characters is ignored; declared on every operation through `CommonRules`, D-090), `Accept-Language: <device locale>`, and `Authorization: Bearer <token>` on authenticated endpoints. A request to an authenticated endpoint without `X-Kvitt-Protocol` or with a value other than `1` gets 426 `protocol_unsupported` (the app then stops syncing until its installed version code changes, and shows the update banner when a newer APK is published, §14.2.4). The protocol check comes before the token check.
|
||||
* Every response carries `X-Kvitt-Server-Version: <crate version>`.
|
||||
* A known path with a method it does not support gets 405 with an empty body (no error code; the only error response without the JSON error shape), the `X-Kvitt-Server-Version` header included. An unknown path gets 404 `not_found` with the JSON error shape.
|
||||
* Unknown JSON fields in requests are ignored. Missing required fields or wrong types: 400 `invalid_request`. Values outside §9.6: 422 `validation_failed` with `field`. The §9.6 value limits are not expressed as JSON Schema constraints in `contracts/openapi.yaml`; the schema fixes types only, and breaking a limit gives 422 `validation_failed`.
|
||||
@@ -2014,7 +2050,7 @@ All server-produced objects. `?` marks nullable (always present). Types: `string
|
||||
"created_at": ts, "updated_at": ts, "server_version": int }
|
||||
|
||||
// Device (only in /me, /devices, /invites/accept)
|
||||
{ "id": uuid, "user_id": uuid, "name": string, "app_version_code": int?, "created_at": ts,
|
||||
{ "id": uuid, "user_id": uuid, "name": string, "app_version_code": int?, "app_version_name": string?, "created_at": ts,
|
||||
"last_seen_at": ts, "revoked_at": ts?, "is_current": bool }
|
||||
|
||||
// Invite (code and link only in the POST /invites response; null in GET /invites)
|
||||
@@ -2076,7 +2112,7 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default
|
||||
|
||||
**E-1 `GET /health`** (none). 200:
|
||||
```json
|
||||
{ "status": "ok", "server_version": "0.1.0", "schema_version": 2, "db_ok": true }
|
||||
{ "status": "ok", "server_version": "0.1.0", "schema_version": 3, "db_ok": true }
|
||||
```
|
||||
`schema_version` comes from a constant in the server code (the version its embedded migrations produce), not from the database, so `/health` answers with the database down. When the database check (`SELECT 1`) fails: 503 with the same shape, `status` `"degraded"`, `db_ok` false. The OpenAPI document declares the 503 body as `oneOf` the health object or Error (an `unavailable` error from a shutting-down server is also possible).
|
||||
|
||||
@@ -2088,11 +2124,11 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default
|
||||
```
|
||||
`display_name` is optional in the schema; it is required for `admin_setup` and `partner` (`invalid_request` if missing), ignored for `device`. 200:
|
||||
```json
|
||||
{ "token": "kvt_…", "user": User, "device": Device, "household": [User, ...] }
|
||||
{ "token": "kvt_…", "invite_kind": "admin_setup" | "partner" | "device", "user": User, "device": Device, "household": [User, ...] }
|
||||
```
|
||||
Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_failed`.
|
||||
`invite_kind` is the kind of the accepted invite (D-086); the app shows S-2 only after `partner` (AC-2.10). The new device's `app_version_code` and `app_version_name` come from the request headers (§8.2). Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_failed`.
|
||||
|
||||
**E-4 `GET /i/{code}`** (none). 200 HTML per AC-2.5, for any `code` string of 1 to 32 characters (validity is not checked). Otherwise 404 HTML, `GET /i/` with an empty code included.
|
||||
**E-4 `GET /i/{code}?from=<name>`** (none). 200 HTML per AC-2.5, for any `code` string of 1 to 32 characters (validity is not checked). Otherwise 404 HTML, `GET /i/` with an empty code included. The optional query parameter `from` (the inviting admin's display name, set by E-12's link) is percent-decoded (`+` as space), trimmed and passed on only into the page's `kvitt://invite?server&code&from` link, never shown; a value that is not UTF-8, holds a control character or has more than 40 code points is dropped and the page still opens (D-087).
|
||||
|
||||
**E-5 `GET /apk/latest`** (none). 200 `latest.json` content (§10.6). Errors: `not_found` (nothing published).
|
||||
|
||||
@@ -2111,7 +2147,7 @@ Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_faile
|
||||
|
||||
**E-11 `GET /users/{user_id}/picture?size=128|512&v=<picture_version>`** (device). 200 `image/jpeg`, `ETag: "<picture_version>"`, `Cache-Control: private, max-age=31536000, immutable`. `size` defaults to 512; other values `invalid_request`. `v` is a cache key only and is ignored. Errors: `not_found` (unknown user or no picture), `invalid_request`.
|
||||
|
||||
**E-12 `POST /invites`** (admin). Request `{ "kind": "partner" | "device", "target_user_id": uuid? }` (`target_user_id` is optional and nullable in the schema, absent meaning null; it is required for `device` and must be null for `partner`). 201 `Invite` with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>`). Errors: `forbidden`, `invalid_request`, `household_full`, `not_found` (target user).
|
||||
**E-12 `POST /invites`** (admin). Request `{ "kind": "partner" | "device", "target_user_id": uuid? }` (`target_user_id` is optional and nullable in the schema, absent meaning null; it is required for `device` and must be null for `partner`). 201 `Invite` with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>?from=<percent-encoded admin display name>`, D-087; the app also reads links without `from`). Errors: `forbidden`, `invalid_request`, `household_full`, `not_found` (target user).
|
||||
|
||||
**E-13 `GET /invites`** (admin). 200 `{ "invites": [Invite, ...] }` (pending only: not accepted, not cancelled, not expired; `code` and `link` null). Errors: `forbidden`.
|
||||
|
||||
@@ -2143,7 +2179,7 @@ Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_faile
|
||||
|
||||
**E-27 `DELETE /categories/{category_id}`** (device). 200 `Category` (tombstone). Errors: `not_found`, `category_in_use`, `category_is_default`.
|
||||
|
||||
**E-28 `POST /recurring-templates`** (device). Request: the `RecurringTemplate` fields `account_id`, `category_id`, `amount_ore`, `description`, `notes`, `payer_user_id`, `split_mode`, `splits` (TemplateSplit, rules of §9.1: none for `default`; two bp entries for `ratio` and `full`; two öre entries summing to the amount for `exact`), `day_of_month`, `start_period`, `end_period`, `paused`. 201 `RecurringTemplate`. If a period is already due (§9.5), the server generates it before responding. Archived accounts and categories are accepted (AC-6.4). Errors: `invalid_request`, `validation_failed` (also for an unknown or deleted `account_id` or `category_id`, with `field`), `household_incomplete`.
|
||||
**E-28 `POST /recurring-templates`** (device). Request: the `RecurringTemplate` fields `account_id`, `category_id`, `amount_ore`, `description`, `notes`, `payer_user_id`, `split_mode`, `splits` (TemplateSplit, rules of §9.1: none for `default`; two bp entries for `ratio` and `full`; two öre entries summing to the amount for `exact`), `day_of_month`, `start_period`, `end_period`, `paused`; no `id`: the server mints it and returns it in the 201 body (D-091). 201 `RecurringTemplate`. If a period is already due (§9.5), the server generates it before responding. Archived accounts and categories are accepted (AC-6.4). Errors: `invalid_request`, `validation_failed` (also for an unknown or deleted `account_id` or `category_id`, with `field`), `household_incomplete`.
|
||||
|
||||
**E-29 `PATCH /recurring-templates/{template_id}`** (device). Any subset of the E-28 fields; `split_mode` and `splits` must be sent together. 200 `RecurringTemplate`. Errors: `invalid_request`, `validation_failed`, `not_found`.
|
||||
|
||||
@@ -2290,14 +2326,14 @@ Every rejection, at steps 3 and 4 too, carries `current` = the stored row with t
|
||||
3. Apply results in one Room transaction:
|
||||
* `accepted`: if the row's `pending_mutation_id` still equals the sent `mutation_id`, overwrite the local row with `entity`, set `pending = false`, `pending_mutation_id = null`. If it differs (edited again during the request), set only `server_version`, `created_by_user_id`, `updated_by_user_id` from `entity` and leave it pending.
|
||||
* `rejected` with `current` equal to the pushed entity in every client-writable field: treat as `accepted` with `current` (no rejection record). This covers re-pushes of an already stored state.
|
||||
* other `rejected`: if `pending_mutation_id` still matches, overwrite the local row with `current` (or delete the local row and its children if `current` is null), set `pending = false`, and insert a `sync_rejections` row with the pushed entity as `rejected_payload`. If it no longer matches, insert the rejection record only and leave the newer local edit pending. Reason `clock_skew`: do not overwrite and do not clear pending; give the row a new `pending_mutation_id` (the server stored the rejection under the old one and would replay it), set `sync_state.clock_skew = true` and stop the cycle (D-027). 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 (D-027).
|
||||
* other `rejected`: if `pending_mutation_id` still matches, overwrite the local row with `current` (or delete the local row and its children if `current` is null), set `pending = false`, and insert a `sync_rejections` row with the pushed entity as `rejected_payload`. If it no longer matches, insert the rejection record only and leave the newer local edit pending. Reason `clock_skew`: do not overwrite and do not clear pending; give the row a new `pending_mutation_id` (the server stored the rejection under the old one and would replay it), set `sync_state.clock_skew = true` and stop the cycle (D-027). Re-stamp (D-094, revising D-027 as implemented by M12e): when `sync_state.clock_skew` is set at the start of a cycle, every pending expense and settlement whose `updated_at` is later than the phone's clock now gets `updated_at` = now (and a tombstone's `deleted_at` too, when it is later than now), in one Room transaction before pushing; rows stamped no later than now are untouched, so a row stamped by a clock that is still ahead is refused again until the clock is fixed, and a corrected clock heals it. Room schema unchanged.
|
||||
4. Repeat until no pending rows remain or a batch made no progress, then pull.
|
||||
|
||||
#### 14.4.6 Rejection reasons
|
||||
|
||||
| `reason` (exhaustive) | When | Phone's user-facing explanation (S-16) |
|
||||
|---|---|---|
|
||||
| `stale` | Someone saved a newer version first | "Your change was replaced by a newer change from <actor>" |
|
||||
| `stale` | Someone saved a newer version first | "Your change was replaced by a newer change from <actor>"; <actor> is `Rejection.actor`, the `updated_by_user_id` of the stored `current` (`sync_rejections.server_current`), the partner when unknown (D-089) |
|
||||
| `deleted` | The row was deleted | "This was deleted before your change arrived" |
|
||||
| `validation_failed` | The row breaks a rule (should not happen with a correct phone) | "This change was not valid and was not saved" |
|
||||
| `reference_not_found` | Its account or category was deleted | "Its account or category no longer exists" |
|
||||
@@ -2548,7 +2584,7 @@ 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).
|
||||
`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 remaining contract requests).
|
||||
|
||||
Data-only messages (no `notification` block), all values strings. Types (exhaustive):
|
||||
|
||||
@@ -2571,7 +2607,7 @@ 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)`, a `:core:model` type that as built still lives in `:adapter:fcm`, §11.3; `:app` handles it in an expedited WorkManager job, D-085):
|
||||
Phone handling (`:adapter:fcm`'s `PushMapping` maps the message to `PushEvent(type, entityType, entityId, actorUserId, count)`, a `:core:model` type in `model/Push.kt` since D-095; `:app` handles it in an expedited WorkManager job, D-085):
|
||||
|
||||
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`.
|
||||
@@ -2657,7 +2693,7 @@ Strings: each feature module has its own `src/main/res/values/strings.xml` and `
|
||||
|
||||
### 14.8 Contract files and the change rule
|
||||
|
||||
Contract files (exhaustive): `contracts/openapi.yaml`, `contracts/schema/server-v1.sql`, the locked migration texts `contracts/schema/migrations/*.sql` (0002 since D-054, §14.1.1), `contracts/category-keywords.csv`, `contracts/test-vectors/*.json`, `contracts/examples/**`, the Room exported schema `android/data/schemas/**` (version 1 exists since M2b, D-012), `android/core/model/**` (the UI contract and domain types), the sync protocol (§14.4) and the push payload (§14.6).
|
||||
Contract files (exhaustive): `contracts/openapi.yaml`, `contracts/schema/server-v1.sql`, the locked migration texts `contracts/schema/migrations/*.sql` (0002 since D-054, §14.1.1; 0003 since D-090, §14.1.2), `contracts/category-keywords.csv`, `contracts/test-vectors/*.json`, `contracts/examples/**`, the Room exported schema `android/data/schemas/**` (version 1 exists since M2b, D-012), `android/core/model/**` (the UI contract and domain types), the sync protocol (§14.4) and the push payload (§14.6).
|
||||
|
||||
`contracts/category-keywords.csv` (D-049) is the keyword table of §9.11 CS-6: UTF-8 without BOM, `\n` line ends, header `keyword,match,default_key,source`, no quoting. `keyword` is non-empty and already normalised (CS-4 applied to it gives it back), unique in the file; `match` is `token`, `prefix` or `phrase`; a `token` or `prefix` keyword has no space and a `phrase` keyword has at least one; `default_key` is a key of AC-7.2 other than `other`; `source` is `data` (learned from the household's export) or `curated`, documentation only. Rows are sorted by `default_key`, then `keyword`, in code-point order. The phone bundles it through a build-time copy (D-055: `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords`, no committed copy, a missing file fails the build, a `:data` test asserts byte identity and these rules, §14.2.4); M12a seeds the file from `docs/ux/category-keywords.csv` (148 entries). Changing an entry is a contract change like any other.
|
||||
|
||||
@@ -2678,6 +2714,8 @@ The document-wide rules of §14.3.1 and §14.3.2 live in one place on the genera
|
||||
|
||||
Rule: a change to any other contract file, or a semantic change to `contracts/openapi.yaml` or to §14 of this spec, requires, landed together by the orchestrator: a spec version bump (in the header and the change log), a `docs/decisions.md` entry, and the matching implementation changes on both sides, or a plan that schedules them. A schema change also bumps the schema version (`meta.schema_version` and a new migration on the server, whose locked text is the contract file `contracts/schema/migrations/<NNNN>_<name>.sql`, §14.1.1; there is no `server-v2.sql`, and `server-v1.sql` stays the text of 0001; a new Room version and migration on the phone). A sync protocol change bumps `X-Kvitt-Protocol`; a push payload change bumps `v`. Numbers come from the orchestrator's brief. Implementation agents never edit `SPEC.md` or `docs/decisions.md`: they put the proposed spec text and decision entry in their report, and the orchestrator has a docs agent record them (CLAUDE.md › Orchestration).
|
||||
|
||||
**Compatibility (v0.13, M12e).** The phone's DTOs give new wire fields no default, by design, so a phone running the M12e APK against a pre-M12e server (schema 2) cannot pair (E-3 lacks `invite_kind`) or list devices (Device lacks `app_version_name`). The server, the development stack included, is rebuilt and redeployed (migration 0003 runs at start) before the new APK is installed. Phones running an older APK work against the new server: they ignore the unknown fields and the server ignores a missing `X-Kvitt-App-Version-Name`.
|
||||
|
||||
---
|
||||
|
||||
## 15. Change log
|
||||
@@ -2696,3 +2734,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
|
||||
| 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. |
|
||||
| 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<Pairing(user, inviteKind)>`, 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 `<public_url>/i/<code>?from=<admin name>`; 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). |
|
||||
|
||||
+16
-14
@@ -3,25 +3,27 @@
|
||||
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
|
||||
|
||||
## 1. Where things stand
|
||||
- `main` at `00a14a3` (M8 app integration, second commit) plus the SPEC v0.12 docs commit from `agent-docs-v012` 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.
|
||||
- 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).
|
||||
- `main` at `90505a8` (M12e launcher icon) plus the SPEC v0.13 docs commit from `agent-docs-v013` 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.
|
||||
- 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).
|
||||
- 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). Not yet run: the end-to-end pairing, offline and push test on the phone and the design-spec §2.7 typeface check (phone was not attached; §3 item 1).
|
||||
- In flight: M12e contract pass (started 2026-10-18; SPEC §11.3 list; proposes D-086 onwards; spec v0.13 next). `agent-docs-v012` (this docs change: SPEC v0.12).
|
||||
- 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 left unchanged by assumption (owner question open, §3 item 5).
|
||||
- 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-v013` (this docs change: SPEC v0.13).
|
||||
- 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 (owner's session in `~/orca/workspaces/kvitt/UX-sprint`, branch `UX-sprint`) has landed; nothing is in flight there.
|
||||
- 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`; database at schema 2 since 2026-10-18 (migrated after a backup). Backup `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` exists (git-ignored). Its bootstrap pairing code is held by the orchestrator outside the repo and expires 2026-10-21T12:28Z. No scripted partner device exists yet (it needs the admin token, available only after the owner pairs). 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` built from `c28c52b`, as of the last report; the M8 build is not installed yet (phone not attached).
|
||||
- Development stack (M2c-dev): running on the Mac, reachable at `http://192.168.1.210:8080`; already rebuilt from `main` `90505a8` on 2026-10-18 (M12e server), database at schema 3, data kept, bootstrap code unchanged. 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`. Its bootstrap pairing code is held by the orchestrator outside the repo and expires 2026-10-21T12:28Z. No scripted partner device exists yet (it needs the admin token, available only after the owner pairs). 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` built from `c28c52b`, as of the last report; the M8 and M12e builds are not installed yet (phone not attached); the dev server is already at M12e, so the M12e debug build can be installed.
|
||||
|
||||
## 2. Open work (one line each, with who owns it: agent or owner)
|
||||
- Land `agent-docs-v012` (orchestrator): SPEC v0.12, D-081 to D-085, HANDOFF, `prompts/README.md`.
|
||||
- M8 on-phone test (orchestrator with the owner, §3 item 1): install the M8 debug build, pair against the dev server, script a partner device with the admin token, run the offline and push checks; then the design-spec §2.7 typeface check (One UI font styles on the S26).
|
||||
- M12e contract pass (in flight): the SPEC §11.3 list, including M8's additions (`PushEvent` to `:core:model`, S-22 licences (OQ-5), detail-screen snackbar hosts, S-7/S-8 snackbar strings as resource ids, launcher icon, the `fid` field on the server).
|
||||
- Land `agent-docs-v013` (orchestrator): SPEC v0.13, D-086 to D-095, HANDOFF, `prompts/README.md`.
|
||||
- On-phone test (orchestrator with the owner, §3 item 1): install the debug build from `main` (M12e; the dev server already runs M12e), pair against the dev server, script a partner device with the admin token, run the offline and push checks; then the design-spec §2.7 typeface check (One UI font styles on the S26).
|
||||
- Remaining contract requests (SPEC §11.3, no plan yet): S-22 licences screen (OQ-5, default not in v1); the server sends the FCM installation id in `fid` instead of `token` (D-083).
|
||||
- Image build for the Pi (M2c-pi or M10): since M4 the arm64 server image needs a C toolchain in the build stage (aws-lc-rs) and CA roots in the distroless image (§10.3); confirm both and one FCM send from the shipped image.
|
||||
- Later cleanup (any Android plan that owns `android/gradle/**`): remove the unused Vico entries from `libs.versions.toml` (D-063).
|
||||
- Plans (SPEC §11.2 v0.12), in dependency order; prompt `prompts/<id>.md`:
|
||||
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8, M9, M12a, M12b, M12c, M12d, U1, U2: landed.
|
||||
- Now: M12e contract pass.
|
||||
- Plans (SPEC §11.2 v0.13), in dependency order; prompt `prompts/<id>.md`:
|
||||
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8, M9, M12a, M12b, M12c, M12d, M12e, U1, U2: landed.
|
||||
- Now: the on-phone test (§3 item 1).
|
||||
- Next: M2c-pi Infra on the Pi (blocked on OQ-3; owner: answer OQ-3, grant the Pi, Home Assistant Pi, Gitea and Drive allowances at the start, D-023); M10 First release after M2c-pi (owner: keystore per §10.6, D-024); 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)
|
||||
@@ -48,9 +50,9 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
|
||||
- M2c-pi: OQ-3.
|
||||
|
||||
## 6. Numbering
|
||||
- Decisions: D-001 to D-085 used (D-008 and D-009 unused). Next free: **D-086** (M12e reserved D-086 to D-095).
|
||||
- Spec version: v0.12 written; next **v0.13** (M12e).
|
||||
- Server schema version **2** (migration 0002, landed with M12b); next **3**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); M12e may propose **2**.
|
||||
- Decisions: D-001 to D-095 used (D-008 and D-009 unused). Next free: **D-096**.
|
||||
- Spec version: v0.13 written; next **v0.14**.
|
||||
- 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**.
|
||||
- Sync protocol **1**, push payload **1**.
|
||||
|
||||
## 7. How the work is run
|
||||
|
||||
+14
-4
@@ -31,7 +31,7 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| 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`); `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). | 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-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-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 `<v>-removed` marker in `pictures/<user_id>/`; 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 |
|
||||
@@ -75,10 +75,10 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| 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 "<range> · Hushållet" for the household measure and "<range> · 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 "<name>, <range caption>". 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-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`); 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-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. | 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). | 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-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) |
|
||||
@@ -90,3 +90,13 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| 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. (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/<CODE>`, 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-086 | 2026-10-18 | E-3's response gains `invite_kind` (the accepted invite's kind). `SessionRepository.pair` returns `OnlineResult<Pairing(user, inviteKind)>`; 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 `<public_url>/i/<code>?from=<percent-encoded admin display name>`. 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) |
|
||||
| D-089 | 2026-10-18 | `Rejection.actor: User?`, from `updated_by_user_id` of the server's `current` row stored in `sync_rejections.server_current`; S-16's stale copy names the partner when it is null. No protocol change. | M7b report (§11.3 list): the stale copy always named the partner. | SPEC §14.2.4, §14.4.6 |
|
||||
| D-090 | 2026-10-18 | New optional request header `X-Kvitt-App-Version-Name` on every operation (`CommonRules`), stored at E-3 and on the hourly last-seen update; blank or longer than 32 characters is ignored. Server migration `0003_device_version_name.sql` adds `devices.app_version_name TEXT CHECK (NULL or length 1..32)`; server schema **3**, E-1 reports 3, locked contract copy with its byte-identity test, a new v2→v3 data-preserving migration test. `app_version_name` on the Device wire object; `Device.appVersionName`; S-20 shows "Kvitt 1.1". `kvitt-import` expects schema 3. Room schema unchanged at 1 (the phone stores no devices). | M7f report (§11.3 list): S-20 showed the version code ("Kvitt 11") where the design shows the name. | SPEC §8.2, §10, §10.8, §14.1, §14.1.2 (new), §14.3.1, §14.3.3, §14.3.4 (E-1, E-3), §14.8, AC-3.1, §13.3 (S-20), §14.2.4; `contracts/schema/migrations/0003_device_version_name.sql`, examples E-1, E-3, E-7, E-15 |
|
||||
| D-091 | 2026-10-18 | `RecurringRepository.create(NewRecurringTemplate)` takes the template fields without an id; the server mints it (E-28's body never had one) and `create` returns it. The S-19 editor builds `RecurringForm.newTemplate()` for a new template and `template(original, …)` for an edit. | M7f report (§11.3 list): the editor sent `TemplateId("")`. | SPEC §13.3 (S-19), §14.2.4, §14.3.4 (E-28) |
|
||||
| D-092 | 2026-10-18 | `UpdateRepository.check(): UpdateCheck` = `UpToDate` \| `Available(info)` \| `Failed`. A 404 from E-5 means up to date; debug builds report `UpToDate` without a request. S-22 shows `settings_about_up_to_date` or, on failure, `common_could_not_reach_server`. Revises D-073 and AC-16.6. | M7f report (D-073, §11.3 list): S-22 could not tell "up to date" from a network failure. | SPEC AC-16.6, §13.3 (S-22), §14.2.4 |
|
||||
| D-093 | 2026-10-18 | `SettleState.untouched: List<UntouchedAccount(account, payerNetOre)>`: 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 |
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
One ready-to-paste start prompt per plan (`PLAN-<id>.md` at the repository root), plus `ORCHESTRATOR-prompt.md`. Before pasting, the orchestrator fills `{{NEXT_DECISION}}`, `{{SPEC_VERSION}}`, `{{SCHEMA_VERSION}}` (disjoint ranges for agents that run in parallel, CLAUDE.md › Numbering) and `{{ORCHESTRATOR_NOTES}}` (answers to the plan's open points, phone assignment, overrides). `U2.md` also takes `{{OWNER_PICK_AND_COMMENTS}}`. U1 and U2 run in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`. The numbers are for proposed entries in agents' reports: implementation agents never edit `SPEC.md` or `docs/decisions.md`; a docs agent records what the orchestrator accepts (CLAUDE.md › Orchestration).
|
||||
|
||||
Dependencies follow SPEC §11.1 and §11.2 (v0.12). 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) started 2026-10-18 and holds D-086 to D-095 for v0.13. 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.
|
||||
Dependencies follow SPEC §11.1 and §11.2 (v0.13). 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). 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.
|
||||
|
||||
## Dependency order and waves
|
||||
|
||||
@@ -34,7 +34,7 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7
|
||||
| 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 outstanding |
|
||||
| 4 | M12e Contract pass | none (orchestrator brief) | M8, SPEC v0.12 (§11.3 list) | started 2026-10-18 |
|
||||
| 4 | M12e Contract pass | none (orchestrator brief) | M8, SPEC v0.12 (§11.3 list) | done (`1470a2f`, `90505a8`) |
|
||||
| 5 | M2c-pi Infra on the Pi | `M2c-pi.md` | M2c-dev, M8; OQ-3 answered | none needed |
|
||||
| 6 | M10 First release | `M10.md` | M2c-pi, M3, M4, M8 | none |
|
||||
| 7 | M11 Import history | `M11.md` | M9, M10 | none |
|
||||
@@ -47,5 +47,5 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7
|
||||
- **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.
|
||||
- **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 is run by the orchestrator with the owner; M12e does not use the phone.
|
||||
- **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 is run by the orchestrator with the owner; 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 (the Kvitt Pi, the Home Assistant Pi, Gitea, Drive: allowances requested at the start of that sprint, D-023), M8 (dev server on the Mac), M10 and M11 (production) need the owner's allowance in the notes.
|
||||
|
||||
Reference in New Issue
Block a user