diff --git a/PLAN-M4.md b/PLAN-M4.md index 0dc0456..13379ad 100644 --- a/PLAN-M4.md +++ b/PLAN-M4.md @@ -27,6 +27,8 @@ Implement the sync protocol (pull and push), the append-only audit log with its - `rotate-epoch` sets `meta.sync_epoch` to a new `lower(hex(randomblob(16)))`, exit 0 (§10.4, §14.1). - Environment variables only those of §10.4; the FCM endpoints are code constants, overridable only through code (constructor parameters) for the stub-server tests. +- From SPEC v0.5 (D-032): M3's rate limiter keys per-IP limits on `ConnectInfo`; M4 changes `serve` to run the router with `into_make_service_with_connect_info::()` (§11.3 note M4). Until then all clients share one IP bucket. + ## 4. Owned paths From SPEC §14.7 (v0.3): `kvitt-server`: `src/api/sync.rs`, `src/tasks/**`, `src/sync/**`, `src/audit/**`, `src/push/**`, `src/scheduler/**`, `src/cli/rotate_epoch.rs`, `tests/sync_*.rs`, `tests/scheduler_*.rs`, `tests/push_*.rs` (owner M4, shells from M2a; after landing: "later server plans"). Also from §14.7: M4 may add dependency lines to a crate's `Cargo.toml` (second to land resolves `Cargo.lock` on rebase) and one line per new module to `mod.rs` files it needs. diff --git a/PLAN-M6.md b/PLAN-M6.md index 9aa8437..897f66b 100644 --- a/PLAN-M6.md +++ b/PLAN-M6.md @@ -27,6 +27,7 @@ Implement `:core:design` from the approved `docs/ux/design-spec.md`: theme, colo - M2b created the module shell (build file, manifest, empty string files); M6 owns it and fills it in (D-004). - Screenshot goldens are committed; open every changed PNG before committing (CLAUDE.md). - `:core:design` depends only on `:core:model` (§6.2). +- Avatars (v0.5, §14.2.4 readings): `:data` always loads pictures at size 512 (E-11); the 128 variant is unused. If the avatar component needs the small size, ask the orchestrator for a size parameter (a contract request on `:core:model` and `:data`). ## 4. Owned paths From SPEC §14.7, verbatim: `android/core/design/**` (owner M6; after landing: "M7x may not edit; requests go through the orchestrator"). diff --git a/PLAN-M8.md b/PLAN-M8.md index f16bd65..a46cab7 100644 --- a/PLAN-M8.md +++ b/PLAN-M8.md @@ -29,6 +29,8 @@ Assemble the app: `:app` navigation and deep links, Hilt bindings from the `:cor - Version properties `kvitt.versionCode`, `kvitt.versionName` (debug `1` and `dev`); release signing from `android/local.properties` keys `kvitt.release.storeFile`, `kvitt.release.storePassword`, `kvitt.release.keyAlias`, `kvitt.release.keyPassword` (§10.6). - During development the debug build pairs against the server on the Mac over the home Wi-Fi (D-001); development data on that server is disposable. - Application ids `se.hagstedt.kvitt.debug` (debug), `se.hagstedt.kvitt` (release) (CLAUDE.md). +- M5's contract requests, landed with SPEC v0.5 (§11.3 note M8, D-041): `:app` provides `AppBuildInfo(versionCode, versionName, debug)` to `:data` and includes `DataBindingsModule` (`@DisableInstallInCheck`) in the release graph, while debug binds the fakes; WorkManager is configured with `KvittWorkerFactory` and the default `WorkManagerInitializer` disabled in the manifest; `:app` calls `SyncTriggers.onAppStart`, `SyncTriggers.onForeground` and `SyncTriggers.syncForPush` (push handling, 20 s wait), and `UpdateRepository.check` on cold start and every resume; the `ImageLoader` provided by `:data` is set as Coil's singleton. +- Open follow-up (D-027): on the first sync attempt after a `clock_skew` rejection the phone re-stamps `updated_at` of each such pending row to the current clock before pushing. Not in `:data` at `c28c52b`; the orchestrator assigns it to M8 (as a contract request on `:data`) or to a small `:data` agent. ## 4. Owned paths From SPEC §14.7, verbatim: `android/adapter/fcm/**`, `android/app/**` (after the M2b shell) (owner M8; after landing: "later integration plans"). Catalog entries and module includes may be added (§14.7, `android/gradle/**` row). @@ -41,7 +43,7 @@ Network security config (§8.5), manifest hardening (§8.8), release signing and - `android/app/build.gradle.kts`: dependencies on every module, Hilt, Firebase (via `google-services.json`), version properties, release signing config read from `local.properties`. - `android/app/src/main/AndroidManifest.xml`: `MainActivity` with launcher and deep-link filters, FCM service from `:adapter:fcm`, `POST_NOTIFICATIONS`, `REQUEST_INSTALL_PACKAGES`, backup disabled, data extraction rules. - `android/app/src/main/res/xml/network_security_config.xml` (release) and the debug variant; `data_extraction_rules.xml`. -- `android/app/src/main/kotlin/se/hagstedt/kvitt/**`: `Application` (Hilt), `MainActivity`, navigation graph, deep-link parsing, Hilt modules binding interfaces to `:data`, push event handling and notification building, revoke and `Unrecognised` routing, notification-permission request after pairing. +- `android/app/src/main/kotlin/se/hagstedt/kvitt/**`: `Application` (Hilt, WorkManager `Configuration.Provider` with `KvittWorkerFactory`, Coil singleton `ImageLoader`), `MainActivity`, navigation graph, deep-link parsing, the `AppBuildInfo` provider and the release graph including `DataBindingsModule`, the `SyncTriggers` and `UpdateRepository.check` calls, push event handling and notification building, revoke and `Unrecognised` routing, notification-permission request after pairing. - `android/app/src/main/res/values/strings.xml`, `values-sv/strings.xml`: the app name only (from M2b's shell); notification text is built from `:core:design` strings. - `android/adapter/fcm/**`: `FirebaseMessagingService`, token registration, mapping to `PushEvent`, unit tests for the mapping. - Unit tests in `:app`: deep-link parsing, notification text building for each §14.6 type and the generic fallback, notification ids. diff --git a/SPEC.md b/SPEC.md index 60b5695..3df1de5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.4** (2026-10-17). Schema version **1**. Sync protocol version **1**. Push payload version **1**. +Version **0.5** (2026-10-17). Schema version **1**. Sync protocol version **1**. Push payload version **1**. This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it. @@ -119,14 +119,14 @@ The server starts with an empty database. The household is created from the comm * 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. -* AC-2.5 `GET /i/` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=&code=`, 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. +* AC-2.5 `GET /i/` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=&code=`, 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/` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. * AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `https://icehack.asuscomm.com:8443` in release builds) and the code. Codes are normalised per §8.3 before sending. * AC-2.8 Release builds accept only `https://` server URLs. Debug builds also accept `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`-`172.31.*`, `192.168.*`). * AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android `POST_NOTIFICATIONS`). * AC-2.10 A `partner` pairing continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen. * AC-2.11 The admin sees pending invites (kind, created, expiry) in Settings and can cancel each. -* AC-2.12 More than 10 failed invite accepts from one client IP within 60 minutes yield `rate_limited` for the rest of that window. +* 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 @@ -134,14 +134,14 @@ The server starts with an empty database. The household is created from the comm * 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. -* AC-3.5 Devices are never deleted from the database; revocation sets `revoked_at`. +* AC-3.5 Devices are never deleted from the database; revocation sets `revoked_at` and clears the device's `fcm_token` (D-034). ### FR-4 Profiles * AC-4.1 Each user has a display name (limits §9.6) and an optional profile picture. * AC-4.2 A user edits their own display name in Settings (online only). The change reaches the other phone through sync. * AC-4.3 A user sets their own picture from the Android photo picker (no camera capture in v1) or removes it. Upload is online only. -* AC-4.4 The server accepts JPEG, PNG and WebP up to 5 MiB and at most 8000 × 8000 pixels, applies EXIF orientation, centre-crops to a square, re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128, strips all metadata, and increments the user's `picture_version`. Other input is refused with `image_invalid`, `unsupported_media_type` or `payload_too_large`. +* AC-4.4 The server accepts JPEG, PNG and WebP up to 5 MiB and at most 8000 × 8000 pixels, applies EXIF orientation, centre-crops to a square, re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128, strips all metadata, and increments the user's `picture_version` (the first upload is version 1; after a removal the next version follows the highest one ever used, §10.4, D-030). PNG and WebP transparency is flattened onto white (D-034). Other input is refused with `image_invalid`, `unsupported_media_type` or `payload_too_large`. * AC-4.5 Where no picture exists, the app shows the user's initials (first letter of the first two words of the display name, uppercase) on a colour derived from the user id (UX sprint defines the two colours). * AC-4.6 The app caches pictures on disk (Coil) keyed by user id and `picture_version`, so they show offline. @@ -155,7 +155,7 @@ The server starts with an empty database. The household is created from the comm * AC-6.1 Either user creates an account (online) with a name and a default split ratio. The editor starts at 50/50. Ratios are entered as percent with up to two decimals per user, the second user's value always being 100 minus the first; stored as bp (§9.1). * AC-6.2 Account names are unique among non-deleted accounts, compared case-insensitively after trimming (Unicode lowercase). A duplicate is refused with `name_taken`. * AC-6.3 Either user renames an account or changes its default ratio (online). A ratio change affects only expenses created afterwards; existing expenses keep the shares they were saved with. -* AC-6.4 Either user archives or unarchives an account (online), even with a non-zero balance. Archived accounts are hidden from the account pickers for new expenses and recurring templates, shown in a collapsed "Archived" section on Home, still count toward the global balance, still appear in analytics and can still be settled. +* AC-6.4 Either user archives or unarchives an account (online), even with a non-zero balance. Archived accounts are hidden from the account pickers for new expenses and recurring templates, shown in a collapsed "Archived" section on Home, still count toward the global balance, still appear in analytics and can still be settled. Hiding is a picker rule only: the server accepts archived accounts and categories in E-28 and E-29 (as it does in sync pushes, §9.10). * AC-6.5 Either user deletes an account (online) only when no non-deleted expense, no allocation of a non-deleted settlement and no non-deleted recurring template references it. Otherwise `account_not_empty`. Deletion is a soft delete (tombstone) so phones learn of it through sync. * AC-6.6 Accounts are listed by `created_at` ascending, the order used by the settlement proposal (§9.4). * AC-6.7 The account create and edit actions are disabled with a "Needs connection" hint when the phone is offline (§9.9). @@ -178,7 +178,7 @@ The server starts with an empty database. The household is created from the comm | `shopping` | Shopping | Shopping | `shopping_bag` | `pink` | 90 | | `other` | Other | Övrigt | `category` | `grey` | 100 | -* AC-7.3 Either user creates a category (online) with a name, icon and colour. New categories get `sort_order` = current maximum + 10. +* AC-7.3 Either user creates a category (online) with a name, icon and colour. New categories get `sort_order` = the maximum `sort_order` over non-deleted categories + 10 (D-034). * AC-7.4 Either user renames, re-icons, recolours or reorders a category (online). Renaming a default category stores the custom name; "Reset name" (only for default categories) clears it so the localised name shows again. * AC-7.5 Either user archives or unarchives a category. Archived categories are not offered for new expenses or templates but stay on existing ones and in analytics. * AC-7.6 A category is deletable only if no non-deleted expense or non-deleted recurring template uses it (`category_in_use` otherwise). Default categories (with a `default_key`) cannot be deleted (`category_is_default`); they can be archived. Deletion is a soft delete. @@ -264,7 +264,7 @@ The server starts with an empty database. The household is created from the comm * AC-15.1 The phone holds a full local copy of: users, accounts, categories, recurring templates, expenses, settlements and audit entries (Room, §14.2). * AC-15.2 Expenses and settlements can be created, edited and deleted offline. Accounts, categories, recurring templates, profile, pictures, invites and devices need the server; their actions are disabled offline with "Needs connection". -* AC-15.3 Sync runs on app start and every return to the foreground, 2 seconds after the last local write (debounced), every 15 minutes in the background through WorkManager (network constraint), and on every FCM data message. Only one sync runs at a time. +* AC-15.3 Sync runs on app start and every return to the foreground, 2 seconds after the last local write (debounced), every 15 minutes in the background through WorkManager (network constraint), and on every FCM data message (expedited one-off work, waited for up to 20 seconds, AC-13.5). Only one sync runs at a time. WorkManager names in §14.4.1 (D-028). * AC-15.4 The app shows a "Last synced" relative time and a pending-changes count on Home, and a sync status screen (§13). * AC-15.5 Conflicts resolve by last-write-wins per row on the client `updated_at`, deletes are final, and rejected changes are shown to the user (§14.4). * AC-15.6 A phone offline for 14 days with 200 local changes syncs all of them on reconnect (integration test with a scripted clock). @@ -273,7 +273,7 @@ The server starts with an empty database. The household is created from the comm * AC-16.1 The server hosts the latest signed release APK and its metadata (§10.6). * AC-16.2 Release builds call `GET /apk/latest` on every cold start and at most once per 6 hours while resumed. When `version_code` is greater than the installed one, Home shows an "Update available" banner with the version name. -* 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. +* 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. @@ -394,7 +394,7 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2). | UI | Jetpack Compose, Material 3; Material 3 Expressive components where they are stable. The Material 3 artifact is pinned explicitly in the catalog (1.4.0 at M2b) alongside the Compose BOM | Native, modern, screenshot-testable | | Min / target SDK | `minSdk` 36 (Android 16); `compileSdk` and `targetSdk` 37 (the latest stable API when M2b pinned them, in `libs.versions.toml`) | Both phones run Android 16; no legacy paths | | Local DB | Room 3 (`androidx.room3`, 3.0.3 at M2b), KSP only, an `SQLiteDriver` with `androidx.sqlite:sqlite-bundled`, exported schema (D-012) | Full offline copy; migrations with tests | -| DI | Hilt | Standard, works with WorkManager and ViewModels | +| DI | Hilt. `:data`'s `DataModule` provides implementations only; the bindings of the `:core:model` interfaces to them live in `DataBindingsModule` (`@DisableInstallInCheck`), which `:app` includes in the release graph, so the debug fakes do not duplicate bindings (D-041) | Standard, works with WorkManager and ViewModels | | HTTP | Ktor client (OkHttp engine), kotlinx.serialization JSON | Kotlin-first, multiplatform-style DTOs | | Background | WorkManager (periodic 15 min sync, expedited sync) | Survives process death, respects Doze | | Dates | kotlinx-datetime | Allowed in pure modules (no `java.time`) | @@ -403,7 +403,7 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2). | Push | Firebase Cloud Messaging, only inside `:adapter:fcm` | Free tier; vendor isolated per `CLAUDE.md` | | Secrets | Android Keystore: an AES-256-GCM key (non-exportable) encrypts the device token; ciphertext in a private DataStore file | `EncryptedSharedPreferences` is deprecated | | 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 migration tests, Ktor `MockEngine`. Unit tests run on JDK 25 with `--add-exports java.base/jdk.internal.access` and `--add-opens java.base/java.io` for Robolectric, set in `build-logic` for every Android module (D-013). `failOnNoDiscoveredTests` is off only for modules without `src/test` (the shells) and on everywhere else (D-014) | No mocking libraries (`CLAUDE.md`) | +| 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 | Module graph (directory `android/`): @@ -544,7 +544,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in * Tokens do not expire. They stop working only on revocation. * Every authenticated request sends `Authorization: Bearer `. 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. -* On the phone the token is encrypted with an Android Keystore AES-256-GCM key (alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, no export) and the ciphertext and IV are stored in DataStore file `auth.preferences_pb`. The server base URL, user id and device id are stored next to it in plain text. +* On the phone the token is encrypted with an Android Keystore AES-256-GCM key (alias `kvitt_token_key`, `setUserAuthenticationRequired(false)`, no export) and the ciphertext and IV are stored in DataStore file `auth.preferences_pb`. The server base URL, user id and device id are stored next to it in plain text, 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. ### 8.3 Invite codes @@ -577,9 +577,11 @@ Two trusted users. The server is on the public internet on port 8443. Threats in * Unauthenticated endpoints: at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds. * Authenticated endpoints: at most 600 requests per minute per device. +* Keying (D-032): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A request answered 401 also counts against its client IP's unauthenticated limit, and a token that was never accepted is refused with `rate_limited` once that IP is over its limit. All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (`KVITT_TRUSTED_PROXY`, re-resolved at most every 60 seconds), the last entry of `X-Forwarded-For`. Per-IP keying needs the peer address: `serve` runs the router with `into_make_service_with_connect_info::()` (added by M4); without it every client shares one IP bucket. +* `GET /health` from the container healthcheck counts against the loopback IP's limit; at the compose healthcheck interval this stays far below 60 per minute and is accepted. * Invite accept failures: AC-2.12. * Request bodies: 1 MiB maximum, except `PUT /me/picture` at 5 MiB. Larger: 413 `payload_too_large`. Both limits are layers in `router.rs` (§6.3). -* JSON strings are validated for length after trimming leading and trailing whitespace; control characters other than newline in `notes` are refused with `validation_failed`. +* JSON strings are validated for length after trimming leading and trailing whitespace, and are stored trimmed, `notes` included (D-034). Control characters other than newline are refused with `validation_failed` in `notes` only; this is intended. Names and descriptions accept newlines and are not checked for control characters. * The rate limiter is in memory (resets on restart); no table. ### 8.7 Privacy and logging @@ -694,9 +696,9 @@ Consequences, stated so nobody "fixes" them: * The scheduler runs once at server start and then daily at 06:00 Europe/Stockholm (DST-aware: always local 06:00). * For each template with `deleted_at IS NULL` and `paused = 0`, the candidate periods are every month `p` with `start_period ≤ p`, `p ≤ end_period` (if set), `p > last_generated_period` (if set), and whose occurrence instant (the clamped date at 06:00 Europe/Stockholm) is at or before now. -* For each candidate in ascending order the server, in one transaction: creates the expense (server-generated UUIDv7 id; `expense_date` = clamped date; fields copied from the template; `created_by_user_id` and `updated_by_user_id` = the template's `created_by_user_id`; `updated_by_device_id` null; `created_at` = `updated_at` = server now; `recurring_template_id` and `recurring_period` set), writes its splits (mode `default` resolves the account's current default ratio at generation time; other modes use the template's splits, with `ratio`/`full` recomputed by the ratio rule for the template amount), writes an audit entry (`create`, `source` = `recurring`), sets the template's `last_generated_period` (bumping its `server_version`), then enqueues the push (AC-9.6). +* For each candidate in ascending order the server, in one transaction: creates the expense (server-generated UUIDv7 id; `expense_date` = clamped date; fields copied from the template; `created_by_user_id` and `updated_by_user_id` = the template's `created_by_user_id`; `updated_by_device_id` null; `created_at` = `updated_at` = server now; `recurring_template_id` and `recurring_period` set), writes its splits (mode `default` resolves the account's current default ratio at generation time; other modes use the template's splits, with `ratio`/`full` recomputed by the ratio rule for the template amount), writes an audit entry (`create`, `source` = `recurring`), sets the template's `last_generated_period` and `updated_at` (server now), bumping its `server_version`, then enqueues the push (AC-9.6). * The unique index on `(recurring_template_id, recurring_period)` makes generation idempotent; a conflict means the period was already generated and is skipped silently. -* Pausing stops generation. Resuming does not backfill periods whose occurrence passed while paused: on resume the server sets `last_generated_period` to the latest period whose occurrence instant is before now, if that is later than the current value. +* Pausing stops generation. Resuming does not backfill periods whose occurrence passed while paused: on resume the server sets `last_generated_period` to the latest period whose occurrence instant is before now, if that is later than the current value and at or after `start_period`. Resuming a template that has not started yet therefore keeps its `start_period` (D-033). * Changing `day_of_month` or `start_period` never regenerates past periods. * `next_occurrence_date` (in the wire form, computed with `kvitt-domain::next_occurrence_date`, §14.5.2) is the clamped date of the first candidate period after `last_generated_period` that is ≥ `start_period` and ≤ `end_period`; `null` when paused, deleted or past `end_period`. * A template's account may be archived (generation continues). Account deletion is blocked while a non-deleted template references it (AC-6.5). @@ -728,6 +730,8 @@ All string lengths are counted in Unicode code points after trimming. One table, | `GET /sync` `limit` | 1 to 1000, default 500 | `invalid_request` | | picture upload | ≤ 5 MiB, ≤ 8000 × 8000 px, JPEG/PNG/WebP | `payload_too_large`, `image_invalid`, `unsupported_media_type` | +Strings are stored as validated, that is trimmed (§8.6, D-034). + On the phone, a rule of this table that has no domain error token of §14.5.3 (the length limits, the date limits, `day_of_month`, the periods) is reported as `FieldError.error` = `validation_failed` (§14.2.4), the same token as the REST code; a missing required value is `required`. The phone input field for amounts accepts digits, one decimal separator (`,` or `.`) and at most two decimals, without sign, and converts to öre exactly (string parsing, never floating point). @@ -852,7 +856,7 @@ Environment variables (exhaustive): | `KVITT_PUBLIC_URL` | `https://icehack.asuscomm.com:8443` | Used in invite links and the landing page | | `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | Service-account key; if the file is missing, push is disabled and a warning is logged at start | | `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project | -| `KVITT_TRUSTED_PROXY` | `caddy` | Host name whose resolved address may set `X-Forwarded-For`. The server must start when the name does not resolve (as on the development stack, which has no Caddy); then no peer is trusted and the peer address is the client IP (D-022) | +| `KVITT_TRUSTED_PROXY` | `caddy` | Host name whose resolved address may set `X-Forwarded-For`. The server must start when the name does not resolve (as on the development stack, which has no Caddy); then no peer is trusted and the peer address is the client IP (D-022). The name is re-resolved at most every 60 seconds (D-032) | | `RUST_LOG` | `info` | tracing filter | The time zone `Europe/Stockholm` is a constant in code, not configuration. @@ -860,7 +864,7 @@ The time zone `Europe/Stockholm` is a constant in code, not configuration. Files under `KVITT_DATA_DIR`: * `kvitt.sqlite` (+ `-wal`, `-shm`) -* `pictures//-512.jpg` and `-128.jpg`; older versions are deleted after a successful upload +* `pictures//-512.jpg` and `-128.jpg`; older versions are deleted after a successful upload. E-10 deletes the JPEGs and leaves an empty marker file `-removed` in `pictures//`; the next upload uses the highest version on disk (JPEG or marker) plus one, so a cache keyed by (`user_id`, `picture_version`) never shows a removed picture. The first upload is version 1 (D-030). * `apk/latest.json` and `apk/kvitt-.apk` `kvitt-server` subcommands (exhaustive): @@ -1077,9 +1081,9 @@ After M1, M2a and M2b land, these run in parallel without touching each other's | M2b | Android skeleton and `:domain` | Gradle project in `android/` with every module of §6.2, version catalog, architecture check, `:core:model` types and repository interfaces of §14.2.4, `:domain` passing all vectors (including analytics), `:core:fakes` with the empty scenario (§6.2, D-018) for each repository, the Room 3 entities of §14.2.1 and §14.2.2 and `KvittDatabase` version 1 in `:data` with the exported schema (D-012), screenshot tests with Roborazzi (D-010), module shells for every module with one empty entry composable per feature (D-004), the feature convention of §6.2 in `build-logic` (D-017), `:app` launching to an empty themed screen, `:app` string files with the app name only in both languages, lint `MissingTranslation` as an error in every module | Android gate | Plug the phone in once to confirm `installDebug` works | M1 | | M2c-dev | Infra for development (D-001, D-022) | Landed `c885aba`. `infra/dev/compose.yaml` (`server` built from `server/Dockerfile`, no Caddy, no TLS, bind-mounted data, `infra/secrets/` mounted read-only at `/secrets`, `backup` runnable on demand), `infra/dev/.env.example`, the dev scripts in `infra/dev/` (build, up, logs, backup, `copy-google-services.sh`), `infra/README.md` (dev stack and Firebase), `infra/backup/{Dockerfile, backup.sh}` (Drive upload skipped when `RCLONE_FOLDER_ID` is empty), Firebase project and apps through the REST API (§10.7), service-account key and `google-services.json` in `infra/secrets/` | `docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Mac (with the M2a image): `GET /health` answers 200 from the Mac; one local backup run produces a snapshot that passes `PRAGMA integrity_check` | `gcloud auth login`; on the phone on the home Wi-Fi, open the dev server's `/health` and answer yes/no that it shows `"status":"ok"` | M0 (uses the M2a image for the health check when available) | | M2c-pi | Infra on the Pi (D-001) | `infra/` Pi compose stack, Caddyfile, cert-sync image, build, deploy and publish scripts, `infra/README.md` (Pi provisioning, router forwarding, trust of the `gitea.lan` registry), `infra/RESTORE.md`, Drive backups to folder id `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`, Gitea Actions image build from the owner's example workflow in `.gitea/workflows/`, the `/opt/kvitt/import` bind mount (§10.2) | `docker compose -f infra/compose.yaml config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Pi: cert-sync fetched a valid certificate, `https://icehack.asuscomm.com:8443/health` answers from the phone on mobile data; one backup file appears in the Drive folder | Answer OQ-3; grant at the start of the sprint the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive (D-023); set up key-based SSH from the Mac to the Kvitt Pi; provide the example Gitea Actions workflow and the Gitea credentials; install Docker on the Pi if missing; add `gitea.lan` to `insecure-registries` on the Pi; set the router forward 8443 → Kvitt Pi; add the cert-sync public key to the Home Assistant SSH add-on; `rclone authorize "drive"` | M2c-dev, M8 | -| M3 | Server core API | E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (`src/api/rate_limit.rs`), `bootstrap`, `pair-device`, the recurring generation function of §6.3 (D-015) | Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, `next_occurrence_date`) | none | M2a | +| M3 | Server core API | Landed `503cee5` (D-030 to D-034). E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (`src/api/rate_limit.rs`), `bootstrap`, `pair-device`, the recurring generation function of §6.3 (D-015) | Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, `next_occurrence_date`) | none | M2a | | M4 | Server sync, audit, push, scheduler | E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) | -| M5 | Android data layer | DAOs on M2b's Room 3 entities and `KvittDatabase` v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b | +| M5 | Android data layer | Landed `c28c52b` (D-026 to D-029, D-040 to D-043). DAOs on M2b's Room 3 entities and `KvittDatabase` v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b | | M6 | Design system | `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts, formatters (§9.8), shared strings (including the notification copy) per `docs/ux/design-spec.md` and `docs/ux/strings.csv`, screenshot tests of every component in light and dark, Swedish and English | Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b, the §9.7 colour change | | M7a | Feature: onboarding | `:feature:onboarding` screens S-1, S-2, S-3, S-18 | Android gate with screenshot tests of every state, sv and en, light and dark | none | M6 | | M7b | Feature: home and account | `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6 | @@ -1100,7 +1104,8 @@ 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//` and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from `:core:design` (D-019). * **M2c-dev / M2c-pi** (D-001): M2c-dev builds only what development needs on the Mac and runs now; M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) and runs after M8, before M10. -* **M8** is the only plan that edits `:app` after M2b. It owns the `:app` items network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), and copies `google-services.json` from `infra/secrets/` with `infra/dev/copy-google-services.sh` (§10.7). +* **M4** adds `into_make_service_with_connect_info::()` to `serve` so the per-IP rate limits of §8.6 see the peer address (D-032). +* **M8** is the only plan that edits `:app` after M2b. M5's contract requests (v0.5): `:app` provides `AppBuildInfo(versionCode, versionName, debug)` and includes `DataBindingsModule` in the release graph (D-041); WorkManager is configured with `KvittWorkerFactory` and the default initializer disabled; `:app` calls `SyncTriggers.onAppStart`, `onForeground` and `syncForPush`, and `UpdateRepository.check` on cold start and every resume; the `ImageLoader` that `:data` provides is set as Coil's singleton. It owns the `:app` items network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), and copies `google-services.json` from `infra/secrets/` with `infra/dev/copy-google-services.sh` (§10.7). * **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). * **M11** runs the import per §10.8 "Production run" (D-025). @@ -1197,6 +1202,8 @@ Invite landing page (E-4, AC-2.5). Server-rendered HTML, so these strings live i | Open button | Open in Kvitt | Öppna i Kvitt | | Instruction sentence | Install Kvitt, then open this link again on your phone. | Installera Kvitt och öppna sedan den här länken igen på din telefon. | +The download button is omitted while no APK is published; the 404 page shows only "Kvitt" and "404" (AC-2.5). + ### 13.4 Deliverables **Phase 1 (U1): directions.** In `docs/ux/directions/`: @@ -1515,7 +1522,7 @@ Local-only semantics on `expenses` and `settlements`: | `SyncStateEntity` | `sync_state` | `id` (always 1), `cursor` Long (last applied `next_since`, 0 initially), `epoch` String?, `last_success_at` String?, `last_attempt_at` String?, `last_error_code` String? (an error code of §14.3.2 or `network`), `clock_skew` Boolean | `id` | | `SyncRejectionEntity` | `sync_rejections` | `mutation_id`, `entity_type`, `entity_id`, `reason` (§14.4.6), `message`, `rejected_payload` (JSON of the entity as pushed), `server_current` (JSON or null), `rejected_at` String, `dismissed` Boolean | `mutation_id` | -Per-phone preferences (last used account, last used category per account, "install unknown apps" explanation shown) live in DataStore `prefs.preferences_pb`, not Room. +Per-phone preferences (last used account, last used category per account, "install unknown apps" explanation shown, and the installed version code that received a 426 `protocol_unsupported`, D-043) live in DataStore `prefs.preferences_pb`, not Room. The pairing status lives in `auth.preferences_pb` with the token (§8.2, D-043). #### 14.2.3 Migrations @@ -1562,7 +1569,7 @@ data class Share(val userId: UserId, val shareOre: Long, val shareBp: Int?) sealed interface OnlineResult { data class Ok(val value: T) : OnlineResult data object Offline : OnlineResult - data class Failed(val code: String, val field: String?) : OnlineResult // code from §14.3.2 + data class Failed(val code: String, val field: String?) : OnlineResult // code from §14.3.2, or "network" when the server could not be reached (D-029) } sealed interface SaveResult { data class Saved(val id: T) : SaveResult @@ -1723,6 +1730,13 @@ Readings fixed in v0.4 (no type changes): * **Settlement rows in an account's list** carry the allocation to that account (AC-11.7). * **`Share` names.** `se.hagstedt.kvitt.model.Share` (a split share) and `se.hagstedt.kvitt.model.analytics.AnalyticsMeasure.Share` (the measure) share a simple name in different packages. Accepted; code that needs both imports one with an alias or qualifies it. +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). +* **No "update required" state.** `HomeState` has no field for a 426. After a 426 `protocol_unsupported` the phone stops syncing until the installed version code changes (§14.3.1, D-043); the update banner shows only when `UpdateState.Available` reports a newer published APK. Accepted. +* **Pictures.** `:data` always requests E-11 with size 512; the 128 variant is unused by `:data` so far (M6 may ask for a size parameter). +* **Choices documented in `:data` and accepted:** `Rejection.summary` (the rejected entity's own text) in `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt`; `FieldChange` display of `splits`, `allocations` and default category names in `.../data/repo/Timeline.kt`; `RecurringTemplate.draft.date` in `.../data/repo/SettingsRepositories.kt` (template mapping). + ### 14.3 OpenAPI 3.1 outline Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt`, `info.version` `1`, one server `https://icehack.asuscomm.com:8443`, security scheme `device` = HTTP bearer) from this outline. This outline is binding; the YAML file must not add or drop anything. @@ -1730,7 +1744,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: `, `Accept-Language: `, and `Authorization: Bearer ` on authenticated endpoints. A request to an authenticated endpoint without `X-Kvitt-Protocol` or with a value other than `1` gets 426 `protocol_unsupported` (the app then shows the update banner and stops syncing until updated). The protocol check comes before the token check. +* Request headers the app sends on every call: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version: `, `Accept-Language: `, and `Authorization: Bearer ` 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: `. * 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`. @@ -1881,7 +1895,7 @@ Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_faile **E-9 `PUT /me/picture`** (device). Raw image body. 200 `User` (new `picture_version`). Errors: `payload_too_large`, `unsupported_media_type`, `image_invalid`. -**E-10 `DELETE /me/picture`** (device). 200 `User` (`picture_version` null). Idempotent. +**E-10 `DELETE /me/picture`** (device). 200 `User` (`picture_version` null). Idempotent. Leaves the `-removed` marker of §10.4 so the next upload gets a new version (D-030). **E-11 `GET /users/{user_id}/picture?size=128|512&v=`** (device). 200 `image/jpeg`, `ETag: ""`, `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`. @@ -1889,13 +1903,13 @@ Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_faile **E-13 `GET /invites`** (admin). 200 `{ "invites": [Invite, ...] }` (pending only: not accepted, not cancelled, not expired; `code` and `link` null). Errors: `forbidden`. -**E-14 `DELETE /invites/{invite_id}`** (admin). 204. Idempotent for already cancelled. Errors: `forbidden`, `not_found`. +**E-14 `DELETE /invites/{invite_id}`** (admin). 204. Idempotent for already cancelled. An already accepted invite also answers 204 and nothing changes. Errors: `forbidden`, `not_found`. **E-15 `GET /devices`** (device). 200 `{ "devices": [Device, ...] }` ordered by `created_at`, revoked included (with `revoked_at`). **E-16 `DELETE /devices/{device_id}`** (device). 204. Revoking an already revoked device is 204. Errors: `forbidden`, `not_found`, `cannot_revoke_current_device`. -**E-17 `PUT /devices/current/push-token`** (device). Request `{ "fcm_token": string? }` (the key is required, its value nullable; null clears). 204. Errors: `invalid_request`. +**E-17 `PUT /devices/current/push-token`** (device). Request `{ "fcm_token": string? }` (the key is required, its value nullable; null clears). A string must be non-empty: `""` is `invalid_request` (D-034). 204. Errors: `invalid_request`. **E-18 `POST /accounts`** (device). Request: ```json @@ -1905,7 +1919,7 @@ Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_faile **E-19 `PATCH /accounts/{account_id}`** (device). Request `{ "name"?: string, "default_shares"?: [Share, Share] }`. 200 `Account`. Errors: `invalid_request`, `validation_failed`, `name_taken`, `not_found`. -**E-20 `POST /accounts/{account_id}/archive`**, **E-21 `POST /accounts/{account_id}/unarchive`** (device). No body. 200 `Account`. Idempotent. Errors: `not_found`. +**E-20 `POST /accounts/{account_id}/archive`**, **E-21 `POST /accounts/{account_id}/unarchive`** (device). No body. 200 `Account`. Idempotent: in the state already held the row is returned unchanged with no new `server_version` (D-034). Errors: `not_found`. **E-22 `DELETE /accounts/{account_id}`** (device). 200 `Account` (tombstone). Errors: `not_found`, `account_not_empty`. @@ -1913,11 +1927,11 @@ Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_faile **E-24 `PATCH /categories/{category_id}`** (device). Request `{ "name"?: string | null, "icon"?: string, "color"?: string, "sort_order"?: int }` (`name: null` resets a default category's name). 200 `Category`. Reordering several categories is one PATCH per category whose `sort_order` changes. Errors: `invalid_request`, `validation_failed`, `not_found`, `category_is_default`. -**E-25 `POST /categories/{category_id}/archive`**, **E-26 `POST /categories/{category_id}/unarchive`** (device). 200 `Category`. Errors: `not_found`. +**E-25 `POST /categories/{category_id}/archive`**, **E-26 `POST /categories/{category_id}/unarchive`** (device). 200 `Category`. Idempotent as E-20 and E-21 (D-034). Errors: `not_found`. **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. 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`. 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`. @@ -1947,7 +1961,7 @@ M1 writes one JSON example per request and response body above, plus one per err * Conflict rule: per row, last write wins on the client `updated_at`; ties go to the server's copy; **deletes are final and always win**. * Pushes are idempotent per `mutation_id`. * Tombstones are kept forever on both sides and are synced like any other row. -* A sync cycle is: push all pending rows (in batches), then pull until `has_more` is false. One cycle at a time per process (a `Mutex` in the sync engine; WorkManager unique work name `kvitt-sync`, policy `KEEP` for periodic and `APPEND_OR_REPLACE` for one-off). +* A sync cycle is: push all pending rows (in batches), then pull until `has_more` is false. One cycle at a time per process (a `Mutex` in the sync engine; WorkManager unique work names `kvitt-sync` for one-off work, policy `APPEND_OR_REPLACE`, and `kvitt-sync-periodic` for the periodic work, policy `KEEP`; WorkManager cannot share one unique name between periodic and one-off work, D-028). Triggers in AC-15.3; pushes go in batches of 100, pulls in pages of 500. #### 14.4.2 Pull: `GET /sync?since=N&limit=L` @@ -1985,6 +1999,8 @@ Phone, per page, in one Room transaction: Repeat while `has_more`. Then set `last_success_at`. +A 409 `cursor_ahead` answer to a pull is treated as epoch recovery (§14.4.8): cursor 0, epoch cleared, every local expense and settlement marked pending with a new `pending_mutation_id`, and the cycle restarted (D-026). + #### 14.4.3 Push: `POST /sync` Request (1 to 100 mutations, body ≤ 1 MiB): @@ -2055,11 +2071,11 @@ The whole request runs in one `BEGIN IMMEDIATE` transaction; each mutation in it #### 14.4.5 Phone push procedure 1. Read pending expenses and settlements ordered by `local_updated_at` ascending; take up to 100. Build each mutation from the row with `mutation_id = pending_mutation_id`. -2. `POST /sync`. On a network error or 5xx: stop the cycle, keep everything pending, let WorkManager retry with exponential backoff starting at 30 seconds. On 401 `device_revoked`: AC-3.3. On 401 `unauthorized`: `SessionState.Unrecognised` (§14.4.8). +2. `POST /sync`. On a network error or 5xx: stop the cycle, keep everything pending, let WorkManager retry with exponential backoff starting at 30 seconds. On 401 `device_revoked`: AC-3.3. On 401 `unauthorized`: `SessionState.Unrecognised` (§14.4.8). On 429: retry as for 5xx. On 426: stop syncing until the installed version code changes (§14.3.1). A push refused as a whole (400 `invalid_request`, 413 `payload_too_large`, 409 `household_incomplete`, 422 `batch_too_large`) counts as a batch with no progress: the cycle goes on to pull and records the code in `sync_state.last_error_code` (D-040). 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; set `sync_state.clock_skew = true` and stop the cycle. + * 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). 4. Repeat until no pending rows remain or a batch made no progress, then pull. #### 14.4.6 Rejection reasons @@ -2360,7 +2376,7 @@ Ownership (an agent edits only paths its milestone owns; anything else needs the | `contracts/**`, `tools/contracts-check.py` | M1 | contract-change plans only (§14.8) | | `server/Cargo.toml`, `server/Cargo.lock`, every crate's `Cargo.toml`, `server/rust-toolchain.toml`, `server/Dockerfile`, `server/crates/kvitt-domain/**` | M2a | contract-change plans only for `kvitt-domain`; M3, M4 and M9 may add dependency lines to a crate's `Cargo.toml` (second to land resolves `Cargo.lock` on rebase) | | `kvitt-server`: `migrations/`, `src/{main,lib,config,error,router,openapi,auth,time}.rs` (including the `serve` and `migrate` code), `src/db/**`, `src/wire/**`, `src/api/{mod,health,setup}.rs`, `src/cli/{mod,healthcheck}.rs`, M2a's own tests (any `tests/*.rs` without the prefixes `core_`, `sync_`, `scheduler_`, `push_`) | M2a | contract-change plans; M3/M4 may add one line per new module to `mod.rs` files they need; M3 edits the one marked rate-limit line in `router.rs` (D-015) | -| `kvitt-server`: `src/api/{invites,landing,me,pictures,devices,accounts,categories,recurring,proposal,apk,rate_limit}.rs`, `src/cli/{bootstrap,pair_device}.rs`, `tests/core_*.rs` | M3 (M2a creates the shells, D-004) | later server plans | +| `kvitt-server`: `src/api/{invites,landing,me,pictures,devices,accounts,categories,recurring,proposal,apk,rate_limit}.rs`, `src/cli/{bootstrap,pair_device}.rs`, `tests/core_*.rs` (M3's tests are one binary, `tests/core_api/`) | M3 (M2a creates the shells, D-004) | later server plans | | `kvitt-server`: `src/api/sync.rs`, `src/tasks/**`, `src/sync/**`, `src/audit/**`, `src/push/**`, `src/scheduler/**`, `src/cli/rotate_epoch.rs`, `tests/sync_*.rs`, `tests/scheduler_*.rs`, `tests/push_*.rs` | M4 (M2a creates the shells, the `tasks::start` stub and the `AuditWriter`/`PushQueue` traits with no-op implementations as described under "Server seams" in §6.3, D-004, D-015) | later server plans; the trait signatures change only with M3, M4 and M9 agreeing through the orchestrator | | `server/crates/kvitt-import/**` | M9 (M2a creates a crate that builds with a usage-only `main.rs`, D-004) | same | | `android/settings.gradle.kts`, `android/build.gradle.kts`, `android/gradle.properties`, `android/gradle/**`, `android/build-logic/**`, the module shells (build file, manifest, one empty entry composable per feature) of `:core:design`, `:data`, `:adapter:fcm` and every `:feature:*` (D-004) | M2b | later plans may add catalog entries and module includes (rebase resolves) | @@ -2416,3 +2432,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op | 0.2 | 2026-10-17 | Owner answers. D-001: development-first deployment on the Mac; M2c split into M2c-dev (now) and M2c-pi (after M8, before M10); registry is Gitea at `http://gitea.lan` (OQ-1 answered). D-002: local backup files deleted as soon as confirmed on Drive. D-003: Splitwise CSV format confirmed for English and Swedish exports, Swedish category names mapped, accounts "Hemma" and "Vardag" (OQ-2 answered). OQ-4 answered (Drive folder id). §10, §11, §12, §14.7 updated. Clarifications from M1 (no new decisions): non-deleted rows only into domain functions, analytics vector keys, sync push result key sets, E-1 503 body, optional/nullable request fields of E-3, E-12, E-17, E-33, `since` required on E-31, proposal amount range, bp above 10000, `resolveRange` edge cases, §9.6 limits not in JSON Schema, example directory names, `not_implemented` on no operation (§14.3, §14.4.3, §14.5). | | 0.3 | 2026-10-17 | Orchestrator resolutions of the plans' open points. D-004: M2a and M2b create shell files and modules in later milestones' paths, ownership of `Cargo.lock` and every `Cargo.toml` (§11.2, §11.3, §14.7). D-015: server seams: middleware in `router.rs`, rate limit in `src/api/rate_limit.rs` on one marked line, `src/tasks/` for M4, `AuditWriter`/`PushQueue` traits with no-op implementations, `next_occurrence_date` and day clamping in `kvitt-domain` (Rust only), M3 writes the recurring generation function, M4 lands after M3 (§6.3, §9.5, §11, §14.5.2, §14.7). D-016: the OpenAPI equality test compares parsed values; representational edits need no spec bump (§11.3, §14.8). D-017: feature convention, analytics types in `se.hagstedt.kvitt.model.analytics`, string placement and key prefixes, `MissingTranslation` in every module (§6.2, §13.4, §14.5.4, §14.7). D-018: empty fake scenario; `resolveRange` throws a typed `IllegalArgumentException` subclass for an incomplete `CUSTOM` range (§6.2, §14.5.4). D-019: ledger rows and expense editor fields in `:core:design` (§6.2, §11.2). D-020: no bundled font, Material 3 default typeface (§6.2, §13.4). D-021: `sync_mutations` pruning daily at 04:00 (§14.1, §14.4.9). D-022: development infrastructure as M2c-dev built it (`c885aba`): Firebase through the REST API with a gcloud token, no Firebase CLI, secrets and `google-services.json` in `infra/secrets/` copied by `infra/dev/copy-google-services.sh`, Firebase resources and the `kvitt-fcm` service account (`roles/firebasecloudmessaging.admin`) exist; dev stack without Caddy or TLS, bind-mounted data, `infra/secrets/` mounted at `/secrets`, env file `infra/dev/.env.example`, `healthcheck` used by compose; the server starts when `KVITT_TRUSTED_PROXY` does not resolve; new M2c-dev gate (§10 intro, §10.4, §10.7, §11.2, §14.7). D-023: M2c-pi allowances, HUMAN steps, `.gitea/workflows/`, secret modes for uid 1000 (§10.2, §10.3, §11.2, §14.7). D-024: release keystore command, kept outside the repository (§10.6). D-025: production import procedure and the `/opt/kvitt/import` bind mount (§10.2, §10.8). Without a decision id: exact Rust version pin shared by `rust-toolchain.toml` and the Dockerfile (§10.3); the §9.7 colour change lands before M6 (§11.1); per-screen states are U2's list (§13.3); landing page copy (§13.3, AC-2.5); M8 owns network security config, hardening, signing and version properties (§11.2); U1's gate names the six phase-1 screens (§11.2); category table path (§10.8); implementation agents never edit the spec or the decisions log (§14.8). | | 0.4 | 2026-10-17 | Decisions from the M2a (`fdf2093`) and M2b (`fad6fc9`) implementation reports. D-005: the eight normalisations of the OpenAPI equality test and the `CommonRules` modifier in `openapi.rs` (§11.3, §14.8). D-006: the wrong-bp-sum token is `bp_sum_not_10000` with an explicit serde rename in Rust; Kotlin emits the same token (§14.5.1, §14.5.3). D-007: the no-unwrap rule enforced per crate with `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]` (CLAUDE.md). D-010: screenshot tests with Roborazzi 1.76.0 on Robolectric 4.17, `@Config(sdk = [36])`, `roborazzi.test.verify=true`, re-record with `recordRoborazziDebug` (§6.2, §11.2). D-011: `IncompleteCustomRangeException` for an incomplete `CUSTOM` range; `ALL_TIME` with the earliest expense after today gives today..today (§14.5.4). D-012: Room 3 (`androidx.room3` 3.0.3, KSP only, `SQLiteDriver` with `sqlite-bundled`, migrations on an `SQLiteConnection`); M2b created the entities and `KvittDatabase` v1, whose exported schema `1.json` is a contract file; M5 builds on them (§6.2, §11.2, §11.3, §14.2, §14.2.3, §14.7, §14.8). D-013: JDK 25 `--add-exports`/`--add-opens` for Robolectric in `build-logic` (§6.2). D-014: `failOnNoDiscoveredTests` off only for modules without `src/test` (§6.2). D-008 and D-009 unused. Clarifications without a decision id: exact Rust pin (1.99.1) shared by `rust-toolchain.toml` and the Dockerfile, `TARGETARCH` arm64 (shipped) and amd64, distroless pinned by digest (§6.3, §10.3); chrono stands (§6.3); `kvitt-domain` depends only on `serde` (§6.3, CLAUDE.md); `AuditWriter` and `PushQueue` as built (§6.3, §14.7); protocol check before token check (§6.3, §14.3.1); 405 with an empty body and the version header (§14.3.1); body limits as router layers (§8.6); `app_version_code` updated only from a present, parseable `X-Kvitt-App-Version` (§8.2); E-1 `schema_version` from a code constant; E-2 `initialized` means an admin user exists (§14.3.4); `healthcheck` uses the port of `KVITT_BIND`; `cargo run -p kvitt-server -- ` from the workspace root (§10.4); `minSdk` 36, `compileSdk` and `targetSdk` 37, Material 3 1.4.0 pinned beside the Compose BOM, AGP 9 `check` runs only the debug unit-test variant, version code and name from `kvitt.versionCode`/`kvitt.versionName` defaulting to `1`/`dev`, feature convention documented in `android/README.md` (§6.2); `FieldError.error` may be `validation_failed` for §9.6 rules without a domain token (§9.6, §14.2.4); `AnalyticsState` has no error field and a missing custom date resolves as today (§14.2.4, §14.5.4); `DrillDownState.title` stays a `String` of unlocalised parts and S-12 formats its own title (§14.2.4); a settlement row in an account's list shows that account's allocation (AC-11.7, §14.2.4); the `AnalyticsMeasure.Share` / `model.Share` name collision is accepted (§14.2.4, §14.5.4). | +| 0.5 | 2026-10-17 | Decisions from the M3 (`503cee5`) and M5 (`c28c52b`) implementation reports. M3: D-030 picture version after a removal, `-removed` marker, next version = highest on disk + 1, first upload 1 (AC-4.4, §10.4, E-10); D-031 failed accept = any 4xx but 429, refused until 60 minutes after the first failure, fixed windows (AC-2.12); D-032 rate-limit keying by token hash, 401 counts against the IP, never-accepted tokens refused when the IP is over, `KVITT_TRUSTED_PROXY` re-resolved every 60 s, last `X-Forwarded-For` entry, `into_make_service_with_connect_info` added by M4 (§8.6, §10.4, §11.3); D-033 resume moves `last_generated_period` only to a period at or after `start_period` (§9.5); D-034 strings stored trimmed, transparency flattened onto white, `fcm_token` non-empty or null, revoke clears `fcm_token`, idempotent archive keeps `server_version`, new category `sort_order` over non-deleted (§8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26). M5 (renumbered: its D-030 to D-033 are D-040 to D-043): D-026 `cursor_ahead` is epoch recovery (§14.4.2); D-027 `clock_skew` gives a new `pending_mutation_id`, and the phone re-stamps `updated_at` on the next attempt (orchestrator decision, follow-up open) (§14.4.5); D-028 periodic work `kvitt-sync-periodic` (§14.4.1); D-029 `OnlineResult.Failed("network", null)` (§14.2.4); D-040 whole-request push refusals count as no progress, 429 retries, 426 stops (§14.4.5); D-041 `DataBindingsModule` (§6.2); D-042 Room tests on Robolectric, migrations from the exported JSON (§6.2); D-043 pairing status and 426 version code in DataStore (§8.2, §14.2.2). D-035 to D-039 reserved for M4. Clarifications without a decision id: control characters restricted only in `notes`, newlines accepted in names and descriptions (§8.6); landing page without download button while no APK is published, 404 page, `Referrer-Policy` and `Cache-Control` headers (AC-2.5, §13.3); generation sets the template's `updated_at` (§9.5); E-28 accepts archived accounts and categories (AC-6.4); E-14 on an accepted invite is 204 with no change; `/health` from the healthcheck counts against loopback (§8.6); M3's tests in one binary `tests/core_api/` (§14.7); sync triggers, batch and page sizes (AC-15.3, §14.4.1); 426 stops syncing until the installed version changes and `HomeState` has no update-required field (§14.2.4, §14.3.1); pictures always fetched at 512; `NeedsInstallPermission` once (AC-16.3); `Rejection.summary`, `FieldChange` display and `RecurringTemplate.draft.date` as documented in `:data` (§14.2.4); M8 contract requests (§11.3). | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 8dc42c6..1289767 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,51 +3,49 @@ Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this. ## 1. Where things stand -- `main` at `fdf2093` (M2a) plus the SPEC v0.4 docs commit from `agent-spec4` once it lands. History: `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `fad6fc9` M2b, `fdf2093` M2a. -- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2c-dev infra for development (`c885aba`), SPEC v0.3 (`76ee957`), M2b Android skeleton and `:domain` (`fad6fc9`), M2a server skeleton and `kvitt-domain` (`fdf2093`). With `agent-spec4`: SPEC v0.4 with D-005 to D-007 and D-010 to D-014 (D-008 and D-009 unused). -- Both gates exist now: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy -- -D warnings && cargo test` (from `server/`). -- Worktrees in flight under `.claude/worktrees/`: `agent-m3` (M3 server core API), `agent-m5` (M5 Android data layer), `agent-spec4` (this docs change: SPEC v0.4). +- `main` at `c28c52b` (M5) plus the SPEC v0.5 docs commit from `agent-spec5` 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. +- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2a server skeleton and `kvitt-domain` (`fdf2093`), M2b Android skeleton and `:domain` (`fad6fc9`), M2c-dev infra for development (`c885aba`), SPEC v0.4 (`040bf41`), M3 server core API (`503cee5`), M5 Android data layer (`c28c52b`). With `agent-spec5`: SPEC v0.5 with M3's D-030 to D-034 and M5's D-026 to D-029 and D-040 to D-043 (M5's proposals D-030 to D-033 renumbered to D-040 to D-043; D-035 to D-039 reserved for M4). +- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy -- -D warnings && cargo test` (from `server/`). +- Worktrees in flight under `.claude/worktrees/`: `agent-m4` (M4 server sync, audit, push, scheduler), `agent-spec5` (this docs change: SPEC v0.5). - The owner runs the UX sprint (U1, then U2) in a separate Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`; it commits only under `docs/ux/` and rebases on `main` before landing. -- Development infrastructure (M2c-dev): `infra/dev/` compose stack on the Mac (server built from `server/Dockerfile`, on-demand backup, no Caddy, no TLS); Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`; `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` in the main checkout (git-ignored). The M2a image now exists, so the phone health check can run. +- Development stack (M2c-dev): running on the Mac, reachable at `http://192.168.1.210:8080`; the owner confirmed `/health` from the phone on the home Wi-Fi on 2026-10-17. 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: nothing yet (M2b's `installDebug` is pending). ## 2. Open work (one line each, with who owns it: agent or owner) -- Land `agent-spec4` (orchestrator): SPEC v0.4, decisions D-005 to D-014, CLAUDE.md server rules (D-007, `kvitt-domain` deps), PLAN-M2a/M2b "on record", PLAN-M5 (builds on M2b's entities, D-012), v0.4 readings in PLAN-M3, M5, M7b to M7f, prompts/README status. -- Tell `agent-m5` before it lands (orchestrator): M2b already created the Room 3 entities, `KvittDatabase` v1 and `1.json` (D-012); M5 adds DAOs and must not change them; v0.4 readings of §14.2.4 (FieldError `validation_failed`, missing custom date = today, `DrillDownState.title`, settlement row = allocation in an account's list). -- Follow-up in `:core:fakes` (M2b-owned base files; needs a small agent or goes with M5's or M7b's brief): settlement rows in an account's ledger must carry the allocation, not the total (AC-11.7 v0.4); replace the five `TODO(spec)` comments in `android/core/fakes/**` that v0.4 settles; and the `TODO(spec)` in `server/crates/kvitt-domain/src/split.rs` (D-006). -- Plans (SPEC §11.2 v0.4), in dependency order; prompt `prompts/.md`: +- Land `agent-spec5` (orchestrator): SPEC v0.5, decisions D-026 to D-034 and D-040 to D-043, PLAN-M8 (M5's contract requests, D-027 follow-up), PLAN-M6 (picture size note), PLAN-M4 (`into_make_service_with_connect_info`), prompts/README status. +- Tell `agent-m4` before it lands (orchestrator): D-035 to D-039 are its range; v0.5 asks M4 to add `into_make_service_with_connect_info::()` to `serve` (D-032, §11.3); M3's tests are in `tests/core_api/`; generation sets the template's `updated_at` (§9.5); D-033 resume rule. +- D-027 re-stamp (orchestrator assigns: M8 as a contract request on `:data`, or a small `:data` agent): 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. Not in `:data` at `c28c52b`. +- Picture size (M6, if needed): `:data` always fetches E-11 at 512; a size parameter for the 128 variant is a contract request on `:core:model` and `:data`. +- `server/README.md` (server-owned, next server agent): still says M3's tests go in `tests/core_*.rs`; v0.5 §14.7 records the single binary `tests/core_api/`. +- Plans (SPEC §11.2 v0.5), in dependency order; prompt `prompts/.md`: - M1 Contracts and vectors: landed (`c6df2ab`). - M2a Server skeleton and `kvitt-domain`: landed (`fdf2093`). - M2b Android skeleton and `:domain`: landed (`fad6fc9`); outstanding: `installDebug` on the phone (owner plugs it in). - - M2c-dev Infra for development: landed (`c885aba`); outstanding: phone `/health` check against the dev stack (owner). + - M2c-dev Infra for development: landed (`c885aba`); phone `/health` check done (owner, 2026-10-17). - U1 UX directions: owner's session (`UX-sprint`), in progress; owner then picks a direction. - U2 UX design spec: owner's session, after U1 and the pick; owner approves; then the §9.7 colour change goes into the spec (docs agent) before M6. - - M3 Server core API: agent in flight (`agent-m3`). - - M4 Server sync, audit, push, scheduler: can start now (after M2a); lands after M3 (D-015). - - M5 Android data layer: agent in flight (`agent-m5`). - - M6 Design system: after U2 approved and the §9.7 colour change. - - M7a to M7f Features: after M6, in parallel. - - M9 Splitwise importer: after M4. - - M8 App integration: after M3, M4, M5, M7a to M7f. + - M3 Server core API: landed (`503cee5`). + - M4 Server sync, audit, push, scheduler: agent in flight (`agent-m4`); lands after M3 (done). + - M5 Android data layer: landed (`c28c52b`). + - Next waves: M9 Splitwise importer after M4; M6 Design system after U2 approved and the §9.7 colour change; M7a to M7f Features after M6, in parallel; M8 App integration after M7 (and M3, M4, M5). - M2c-pi Infra on the Pi: after M8, before M10 (owner: answer OQ-3, grant the Pi, Home Assistant Pi, Gitea and Drive allowances at the start, D-023). - M10 First release: after M2c-pi, M3, M4, M8 (owner: keystore per §10.6, D-024). - M11 Import history: after M9 and M10. ## 3. HUMAN steps pending (relay in chat) -- Plug the phone in over USB so an agent can run `installDebug` of the M2b skeleton (expected: the Kvitt debug app opens to an empty themed screen). -- Dev-stack health check: on the phone on the home Wi-Fi, open the dev server's `/health` and answer yes/no that it shows `"status":"ok"` (M2c-dev). +- Plug the phone in over USB so an agent can run `installDebug` of the debug app (expected: the Kvitt debug app opens to an empty themed screen). - UX direction pick from U1's directions, with comments (owner's UX-sprint session). ## 4. Owner questions (asked, unanswered) - OQ-3 LAN addresses and SSH users of the Kvitt Pi and the Home Assistant Pi; SSH port of the HA Pi; certificate directory there; which SSH add-on (SPEC §12). Needed by M2c-pi only. The only open question (OQ-1, OQ-2, OQ-4 answered: D-001, D-003, §10.9). ## 5. Paused (waiting on something outside the repo) -- Nothing; the M2c-dev phone check no longer waits on M2a, only on the owner. +- Nothing. ## 6. Numbering -- Decisions: D-001 to D-025 used (D-008 and D-009 unused). Reserved: **D-026 to D-029 for M5**, **D-030 to D-039 for M3**. Next free for anything else: **D-040**. -- Spec version: v0.4 written; next **v0.5** (the first of M3/M5 to land takes it; the second renumbers on rebase). -- Server schema version **1** (implemented by M2a); next **2**. Room schema version **1** (created by M2b, `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. +- Decisions: D-001 to D-034 and D-040 to D-043 used (D-008 and D-009 unused). Reserved: **D-035 to D-039 for M4** (in flight). Next free for anything else: **D-044**. +- Spec version: v0.5 written; next **v0.6**. +- Server schema version **1**; next **2**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`, unchanged by M5); next **2**. - Sync protocol **1**, push payload **1**. ## 7. How the work is run diff --git a/docs/decisions.md b/docs/decisions.md index 81a0b7e..14fce20 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -30,3 +30,21 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-023 | 2026-10-17 | M2c-pi: key-based SSH to the Kvitt Pi and the Gitea credentials are HUMAN steps; the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint; the Gitea Actions workflow lives at `.gitea/workflows/`, owned by M2c-pi; secret files on the Pi (`ha_ssh_key`, `ha_known_hosts`, `rclone.conf`) mode 0600 owned by uid 1000, the uid of the `cert-sync` and `backup` containers; adding `gitea.lan` to `insecure-registries` is its own HUMAN step. | PLAN-M2c-pi open points (a) to (c). | SPEC §10.2, §10.3, §11.2, §11.3, §14.7; PLAN-M2c-pi | | D-024 | 2026-10-17 | Release keystore: `keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000`, created by the owner and kept outside the repository (was `android/keystore/release.jks`); `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-028 | 2026-10-17 | Periodic sync work is named `kvitt-sync-periodic` (policy `KEEP`); one-off work keeps `kvitt-sync` (`APPEND_OR_REPLACE`). | M5 report: WorkManager cannot share one unique name between periodic and one-off work. | SPEC §14.4.1, AC-15.3 | +| D-029 | 2026-10-17 | A REST call that fails on the network returns `OnlineResult.Failed("network", null)`; `network` is the one allowed code outside §14.3.2. | M5 report: the UI must tell "Could not reach the server" (§9.9) from `Offline` (call not attempted). | SPEC §14.2.4 | +| D-030 | 2026-10-17 | Picture version after a removal: E-10 deletes the JPEGs and leaves an empty `-removed` marker in `pictures//`; the next upload uses the highest version on disk plus one, so caches keyed by (`user_id`, `picture_version`) never show a removed picture; the first upload is version 1. | M3 report (`503cee5`): `picture_version` is null after a removal, so the next version needs a record. | SPEC AC-4.4, §10.4, E-10 | +| D-031 | 2026-10-17 | Failed invite accept (AC-2.12): any 4xx answer other than 429 counts; after 10 failures further accepts from that IP are refused with `rate_limited` until 60 minutes after the first counted failure; windows are fixed from the first counted request. | M3 report: AC-2.12 did not define "failed" or the window. | SPEC AC-2.12, §8.6 | +| D-032 | 2026-10-17 | Rate-limit keying (§8.6): per device by the hash of the bearer token, no database lookup; a 401 also counts against the client IP; tokens never accepted are refused once the IP is over its limit; `KVITT_TRUSTED_PROXY` re-resolved at most every 60 s; the client IP is the last `X-Forwarded-For` entry from the trusted proxy; `serve` must use `into_make_service_with_connect_info` (being added by M4). | M3 report: keying and proxy handling were unspecified; the limiter must not cost a database query. | SPEC §8.6, §10.4, §11.3 | +| D-033 | 2026-10-17 | Resume rule (§9.5): `last_generated_period` moves forward only when the latest past period is at or after `start_period`, so resuming a not-yet-started template keeps its start. | M3 report: the v0.4 rule would skip a future start on resume. | SPEC §9.5 | +| D-034 | 2026-10-17 | Storage normalisation: strings stored trimmed, `notes` included; PNG and WebP transparency flattened onto white; `fcm_token` non-empty or null (`""` is `invalid_request`); revoking a device clears its `fcm_token`; archive or unarchive in the state already held returns the row unchanged with no new `server_version`; a new category's `sort_order` is the maximum over non-deleted categories plus 10. | M3 report: details the spec left open. | SPEC §8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26 | +| D-035 | 2026-10-17 | Reserved for M4 (in flight). | | | +| D-036 | 2026-10-17 | Reserved for M4 (in flight). | | | +| D-037 | 2026-10-17 | Reserved for M4 (in flight). | | | +| D-038 | 2026-10-17 | Reserved for M4 (in flight). | | | +| D-039 | 2026-10-17 | Reserved for M4 (in flight). | | | +| D-040 | 2026-10-17 | A push refused as a whole (400 `invalid_request`, 413 `payload_too_large`, 409 `household_incomplete`, 422 `batch_too_large`) counts as a batch with no progress: the cycle goes on to pull and records `last_error_code`; 5xx and 429 retry with backoff; 401 and 426 stop. (Proposed by M5 as D-030, renumbered.) | M5 report: §14.4.5 covered only network errors, 5xx and 401. | SPEC §14.4.5 | +| D-041 | 2026-10-17 | `:data`'s `DataModule` provides implementations only; the interface bindings live in `DataBindingsModule` (`@DisableInstallInCheck`), which `:app` includes for release so the debug fakes do not duplicate bindings. (M5's D-031, renumbered.) | M5 report: Hilt fails on duplicate bindings when debug binds fakes. | SPEC §6.2, §11.3 (M8); PLAN-M8 | +| D-042 | 2026-10-17 | Room tests run on Robolectric with the framework SQLite driver; migration tests build from the exported schema JSON rather than `room3-testing`. (M5's D-032, renumbered.) | M5 report: no device needed for the gate. | SPEC §6.2 | +| D-043 | 2026-10-17 | Two extra DataStore keys: the pairing status in `auth.preferences_pb`, and the installed version code that received a 426 in `prefs.preferences_pb` (syncing stops until the installed version changes). The token is encrypted with Keystore key `kvitt_token_key`, AES-256-GCM (as §8.2). (M5's D-033, renumbered.) | M5 report. | SPEC §8.2, §14.2.2, §14.4.5 | diff --git a/prompts/README.md b/prompts/README.md index 27feb71..bc2b100 100644 --- a/prompts/README.md +++ b/prompts/README.md @@ -2,7 +2,7 @@ One ready-to-paste start prompt per plan (`PLAN-.md` at the repository root), plus `ORCHESTRATOR-prompt.md`. Before pasting, the orchestrator fills `{{NEXT_DECISION}}`, `{{SPEC_VERSION}}`, `{{SCHEMA_VERSION}}` (disjoint ranges for agents that run in parallel, CLAUDE.md › Numbering) and `{{ORCHESTRATOR_NOTES}}` (answers to the plan's open points, phone assignment, overrides). `U2.md` also takes `{{OWNER_PICK_AND_COMMENTS}}`. U1 and U2 run in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`. The numbers are for proposed entries in agents' reports: implementation agents never edit `SPEC.md` or `docs/decisions.md`; a docs agent records what the orchestrator accepts (CLAUDE.md › Orchestration). -Dependencies follow SPEC §11.1 and §11.2 (v0.4). Status as of 2026-10-17: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`fdf2093`), M2b landed (`fad6fc9`); M3 and M5 in flight; U1 in the owner's session. The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014). +Dependencies follow SPEC §11.1 and §11.2 (v0.5). Status as of 2026-10-17: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`fdf2093`), M2b landed (`fad6fc9`), M3 landed (`503cee5`), M5 landed (`c28c52b`); M4 in flight; U1 in the owner's session. The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043; D-035 to D-039 reserved for M4). ## Dependency order and waves @@ -16,9 +16,9 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7 | 1 | M2b Android skeleton and `:domain` | `M2b.md` | M1 | done (`fad6fc9`); `installDebug` on the phone outstanding | | 1 | M2c-dev Infra for development | `M2c-dev.md` | M0 (its health check needs M2a's image) | done (`c885aba`) | | 1 | U2 UX design spec | `U2.md` | U1 and the owner's pick | M2a, M2b, M2c-dev, wave 2 | -| 2 | M3 Server core API | `M3.md` | M2a | M4 (M4 lands after M3), M5, M6 | +| 2 | M3 Server core API | `M3.md` | M2a | done (`503cee5`) | | 2 | M4 Server sync, audit, push, scheduler | `M4.md` | M2a; lands after M3 (D-015) | M3, M5, M6 | -| 2 | M5 Android data layer | `M5.md` | M1, M2b | M3, M4, M6 | +| 2 | M5 Android data layer | `M5.md` | M1, M2b | done (`c28c52b`) | | 2 | M6 Design system | `M6.md` | U2 approved, M2b, the §9.7 colour spec change | M3, M4, M5 | | 3 | M7a Feature onboarding | `M7a.md` | M6 | M7b to M7f, M9 | | 3 | M7b Feature home and account | `M7b.md` | M6 | M7a, M7c to M7f, M9 |