SPEC v0.8: UX-session decisions (D-047 to D-054), category contract v2, M12 plans

Records the owner's UX-sprint decisions UX-1 to UX-8 of docs/ux/proposed-decisions.md:
D-047 direction Kalas muted, D-048 three tabs, D-049 category suggestion (§9.11,
suggestCategory, contracts/category-keywords.csv, suggest_category.json), D-050 S-1
display-name field, D-051 colour token values from Kalas (slate, amber TODO(U2)),
D-052 smaller UX rules, D-053 finer Splitwise mapping, D-054 fourteen default
categories with server schema 2 (migration 0002, §14.1.1). Adds milestone group M12
(PLAN-M12a/b/c, prompts), the CLAUDE.md UX-sprint note, and rewrites HANDOFF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 08:06:45 +02:00
parent aa765bbb48
commit d39eb2c79b
15 changed files with 553 additions and 68 deletions
+223 -43
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.7** (2026-10-18). Schema version **1**. Sync protocol version **1**. Push payload version **1**.
Version **0.8** (2026-10-18). Schema version **2**. Sync protocol version **1**. Push payload version **1**.
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it.
@@ -118,7 +118,7 @@ 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.
* AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the user, the device and the household's users. For a `partner` invite it creates the `member` user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050).
* AC-2.5 `GET /i/<CODE>` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=<urlencoded KVITT_PUBLIC_URL>&code=<CODE>`, the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data. While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404".
* AC-2.6 The app opens from `kvitt://invite?server=…&code=…` and from `https://icehack.asuscomm.com:8443/i/<CODE>` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled.
* AC-2.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.
@@ -142,7 +142,7 @@ The server starts with an empty database. The household is created from the comm
* AC-4.2 A user edits their own display name in Settings (online only). The change reaches the other phone through sync.
* AC-4.3 A user sets their own picture from the Android photo picker (no camera capture in v1) or removes it. Upload is online only.
* AC-4.4 The server accepts JPEG, PNG and WebP up to 5 MiB and at most 8000 × 8000 pixels, applies EXIF orientation, centre-crops to a square, re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128, strips all metadata, and increments the user's `picture_version` (the first upload is version 1; after a removal the next version follows the highest one ever used, §10.4, D-030). PNG and WebP transparency is flattened onto white (D-034). Other input is refused with `image_invalid`, `unsupported_media_type` or `payload_too_large`.
* AC-4.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.5 Where no picture exists, the app shows the user's initials (first letter of the first two words of the display name, uppercase) on one of two colour tokens, `avatar-1` and `avatar-2` (values in U2's `docs/ux/design-spec.md`). A user's slot is a stable function of the user id, so the same user has the same colour on both phones (D-052).
* AC-4.6 The app caches pictures on disk (Coil) keyed by user id and `picture_version`, so they show offline.
### FR-5 Household gate
@@ -163,21 +163,27 @@ The server starts with an empty database. The household is created from the comm
### FR-7 Categories
* AC-7.1 One global, editable category list. A category has a name, an icon token (§9.7) and a colour token (§9.7).
* AC-7.2 The database is seeded with exactly these ten default categories (fixed ids, §14.1), each shown with its localised default name until a user renames it:
* AC-7.2 The database is seeded with exactly these fourteen default categories (fixed ids, §14.1; the four with `sort_order` 25, 45, 85 and 95 were added by migration 0002, §14.1.1, D-054), each shown with its localised default name until a user renames it:
| `default_key` | English name | Swedish name | `icon` | `color` | `sort_order` |
|---|---|---|---|---|---|
| `groceries` | Groceries | Mat & hushåll | `shopping_cart` | `green` | 10 |
| `restaurants` | Restaurants | Restaurang & café | `restaurant` | `orange` | 20 |
| `housing` | Housing | Boende | `key` | `indigo` | 25 |
| `home` | Home | Hem | `home` | `brown` | 30 |
| `utilities` | Utilities | Räkningar | `bolt` | `yellow` | 40 |
| `insurance` | Insurance | Försäkringar | `shield` | `slate` | 45 |
| `transport` | Transport | Transport | `directions_car` | `blue` | 50 |
| `travel` | Travel | Resor | `flight` | `teal` | 60 |
| `entertainment` | Entertainment | Nöje | `movie` | `purple` | 70 |
| `health` | Health | Hälsa | `medical_services` | `red` | 80 |
| `children` | Children | Barn | `child_care` | `lime` | 85 |
| `shopping` | Shopping | Shopping | `shopping_bag` | `pink` | 90 |
| `fees` | Fees & taxes | Avgifter & skatter | `account_balance` | `amber` | 95 |
| `other` | Other | Övrigt | `category` | `grey` | 100 |
`home` keeps furniture, supplies, maintenance, electronics and services; rent and mortgage belong to `housing` (D-054).
* 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.
@@ -191,11 +197,11 @@ The server starts with an empty database. The household is created from the comm
* AC-8.3 The payer is either user. The editor defaults the payer to the current user.
* AC-8.4 The date is any date within §9.6 limits, past or future. It defaults to today in the phone's time zone. This date is the only date used in analytics and lists.
* AC-8.5 Split modes (enum §9.1): `default` (the account's default ratio, copied into the expense when saved), `ratio` (a custom ratio), `exact` (exact öre per user), `full` (the whole amount borne by one chosen user, who may be the payer or not). Share computation per §9.1.
* AC-8.6 The editor defaults: account = the account last used on this phone if still active, else the oldest active account; category = the category last used in that account on this phone if still active, else `other`; split mode `default`.
* AC-8.6 The editor defaults: account = the account last used on this phone if still active, else the oldest active account; category = the category suggestion of §9.11 for a new expense, whose last step is the category last used in that account on this phone if still active, else `other` (D-049); editing an existing expense keeps its category; split mode `default`.
* AC-8.7 Exact mode: the user types one user's share; the other user's share is the amount minus it and is shown read-only. A typed share larger than the amount blocks saving with an inline error.
* AC-8.8 Changing the account of an expense in mode `default` re-copies the new account's ratio. In other modes the split is kept.
* AC-8.9 Deleting asks for confirmation and is a soft delete. Deleted expenses disappear from lists, balances and analytics on both phones after sync.
* AC-8.10 Expense lists show, per row: category icon and colour, description, date, amount, and a one-line effect from the viewer's side ("You lent 205,00 kr", "You borrowed 205,00 kr", "Not involved" for `full` on the payer, matching §13 copy). Rows are ordered by date descending, then `created_at` descending.
* AC-8.10 Expense lists show, per row: category icon and colour, description, date, amount, and a one-line effect from the viewer's side ("You lent 205,00 kr", "You borrowed 205,00 kr", "Not involved" for `full` on the payer, matching §13 copy; Swedish "Du lånade ut 205,00 kr", "Du lånade 205,00 kr", "Inte inblandad", D-052). Rows are ordered by date descending, then `created_at` descending.
* AC-8.11 An expense created from a recurring template is an ordinary expense in every way (editable, deletable) and shows "Recurring" with the template name in the detail view.
### FR-9 Recurring expenses
@@ -437,7 +443,7 @@ Features never call `:domain` directly: repositories return computed state (bala
* Shared UI that two features need is built in `:core:design` by M6 (D-019); a feature never copies another feature's component.
* Strings: `:app` holds only the app name; `:core:design` holds the shared strings (including the notification copy and channel name of §13.3 and §14.6, which `:app` uses); each feature module holds its own. Lint `MissingTranslation` is an error in every module. Keys of a feature module are prefixed by the feature name (`home_`, `account_`, `expense_`, ...); keys in `:core:design` never start with a feature name. This rule wins over any other key convention (§13.4, §14.7).
**Fakes.** Every fake in `:core:fakes` has at least the empty scenario (D-018): the household is complete (two named users, no pictures), there are no accounts, the categories are exactly the ten defaults of AC-7.2, and there are no expenses, settlements, templates, rejections or pending changes. Feature agents add their own scenarios (§11.3).
**Fakes.** Every fake in `:core:fakes` has at least the empty scenario (D-018): the household is complete (two named users, no pictures), there are no accounts, the categories are exactly the fourteen defaults of AC-7.2 (D-054), and there are no expenses, settlements, templates, rejections or pending changes. Feature agents add their own scenarios (§11.3).
### 6.3 Server
@@ -739,11 +745,11 @@ The phone input field for amounts accepts digits, one decimal separator (`,` or
### 9.7 Icon and colour tokens
Category icon tokens are Material Symbols names. The allowed set (exhaustive, v1):
Category icon tokens are Material Symbols names. The allowed set (exhaustive, 33 tokens; `key`, `shield` and `account_balance` added by D-054):
`shopping_cart`, `restaurant`, `local_cafe`, `home`, `bolt`, `water_drop`, `wifi`, `directions_car`, `train`, `local_gas_station`, `flight`, `hotel`, `luggage`, `movie`, `music_note`, `sports_esports`, `fitness_center`, `medical_services`, `medication`, `shopping_bag`, `checkroom`, `pets`, `child_care`, `school`, `card_giftcard`, `celebration`, `build`, `chair`, `subscriptions`, `category`.
`shopping_cart`, `restaurant`, `local_cafe`, `home`, `bolt`, `water_drop`, `wifi`, `directions_car`, `train`, `local_gas_station`, `flight`, `hotel`, `luggage`, `movie`, `music_note`, `sports_esports`, `fitness_center`, `medical_services`, `medication`, `shopping_bag`, `checkroom`, `pets`, `child_care`, `school`, `card_giftcard`, `celebration`, `build`, `chair`, `subscriptions`, `category`, `key`, `shield`, `account_balance`.
Colour tokens (exhaustive, v1) with provisional values; the UX sprint may change the hex values and must keep the token names:
Colour tokens (exhaustive, 14 tokens; `slate` and `amber` added by D-054) with provisional values. The direction is U1's "Kalas" with its muted palette (D-047); U2 replaces these hex values with the muted Kalas values and must keep the token names (D-051). `slate` and `amber` have no value until U2 reports (`TODO(U2)`); M6 starts only after the values have landed here (§11.1).
| Token | Light | Dark |
|---|---|---|
@@ -759,6 +765,8 @@ Colour tokens (exhaustive, v1) with provisional values; the UX sprint may change
| `grey` | `#546E7A` | `#B0BEC5` |
| `indigo` | `#283593` | `#9FA8DA` |
| `lime` | `#827717` | `#DCE775` |
| `slate` | `TODO(U2)` | `TODO(U2)` |
| `amber` | `TODO(U2)` | `TODO(U2)` |
The server validates icon and colour against these lists (`validation_failed`).
@@ -797,6 +805,46 @@ When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or
| Time zones | Dates (`expense_date`, `settlement_date`) are calendar dates chosen by the user; analytics never convert them. Only "today" defaults use the phone's zone; server validation and scheduling use Europe/Stockholm. |
| A day with a DST change | Scheduling uses local 06:00, which exists on every day in Europe/Stockholm. |
### 9.11 Category suggestion
The expense editor (S-7) selects the category from the description while the user types (D-049, owner 2026-10-18). The rule is a pure function in `:domain` (§14.5.6, with platform Unicode services through `UnicodeText`), fed by `ExpenseRepository.suggestCategory` in `:data` (§14.2.4) from Room and the keyword table `contracts/category-keywords.csv` (§14.8).
**CS-1 Scope.** The suggestion runs only in the editor for a **new** expense. It never runs when an existing expense is edited and never changes the category of a saved expense. The recurring template editor (S-19) does not use it in v1.
**CS-2 Manual choice wins.** The editor keeps a flag "category picked by hand", false when the editor opens. Picking any category in the category grid sets it to true, also when the user picks the category already shown. While the flag is true the suggestion never changes the category, for the rest of this editor session (until the editor closes). Nothing resets the flag.
**CS-3 When it runs.** While the flag is false, the category is recomputed on every change of the description and on every change of the account. It is a local computation over Room data; no network.
**CS-4 Normalisation.** Descriptions (typed and stored) and keywords are compared after this normalisation:
1. Unicode NFC, then lowercase without locale rules (Kotlin `lowercase()`, which uses `Locale.ROOT`).
2. Delete every `'` (U+0027), `’` (U+2019) and `:` (so "Willy:s" becomes "willys" and "McDonald's" becomes "mcdonalds").
3. Replace every remaining code point that is not a Unicode letter (general category L), a decimal digit (general category Nd) or `&` with a space. Whitespace is therefore replaced too.
4. Collapse runs of spaces to one space and trim.
Diacritics are kept (å, ä, ö and é stay distinct; the keyword table lists both spellings where it matters). Digits are kept. The **tokens** are the normalised description split on spaces; the **first token** is the first of them. An empty normalised description matches no history and no keyword.
**CS-5 Order.** The suggested category is the first that applies of:
1. **History, exact:** among the non-deleted expenses on the phone (every account, either user, pending, imported and recurring ones included) whose normalised description equals the normalised typed description, the category used most often; on a tie, the one used most recently.
2. **History, first token:** among the non-deleted expenses whose first token equals the typed description's first token, the category used most often; on a tie, the one used most recently.
3. **Keywords:** the category of the winning entry of the keyword table (CS-6).
4. **Default:** the AC-8.6 fallback: the category last used in the selected account on this phone if still active, else `other`.
"Used most often" counts the matching expenses per category. "Most recently" compares the latest matching expense of each tied category by (`expense_date`, then `created_at`), the expense-list order of AC-8.10; if that still ties, the lower category id (string order) wins. Archived and deleted categories are left out of the count; when no category is left, the next step applies (owner, 2026-10-18: most often, not most recent).
**CS-6 Keyword table.** Each entry has a `keyword` (already normalised per CS-4), a `match` and a `default_key`. An entry matches when:
* `token`: some token equals the keyword;
* `prefix`: some token starts with the keyword;
* `phrase`: the keyword's tokens occur consecutively in the tokens.
When several entries match, the longest keyword (in code points, spaces included, a surrogate pair counting as one) wins; on equal length, the entry earlier in the table wins (the table is sorted by `default_key`, then `keyword`, both in code-point order). The winning entry gives the active category with that `default_key` (a renamed default included, since the key stays). If that category is archived, the next matching entry in the same order is tried; when none is left, step 4 applies. The table is fixed data shipped with the app; users do not edit it. No entry maps to `other`.
**CS-7 What the user sees.** The suggestion simply replaces the category shown in the Kategori field: no caption, no marker, no confirmation (owner, 2026-10-18: a wrong guess is corrected with one tap). Clearing the description returns the field to the step-4 default.
Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expenses with the fourteen categories of AC-7.2 and the §10.8 mapping put 79.7 % of rows in the right category, against 30.5 % for the step-4 default alone; with no history, the keywords cover 77 % of rows at 96 % accuracy. The replay is in `docs/ux/category-suggestion.md`.
---
## 10. Deployment & ops
@@ -981,7 +1029,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 `1`, else it exits 1 (run `kvitt-server migrate` first) (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 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.
@@ -994,22 +1042,27 @@ kvitt-import --db /data/kvitt.sqlite --file <csv> --account "<Kvitt account name
* `import_key` = lowercase hex SHA-256 of `splitwise|v1|<account name>|<Date>|<Description>|<Category>|<Cost>|<Currency>|<member nets in header order, joined with |>|<n>` where `<n>` is the 0-based occurrence index of an identical line earlier in the same file. Rows whose key exists are skipped. Idempotency uses the existing `import_key` columns of `expenses` and `settlements` (§14.1); no new column or table (D-046).
* Every created row: `created_by_user_id` = `updated_by_user_id` = the owner; `updated_by_device_id` null; `created_at` = `updated_at` = the import time; one audit entry `create` with `source` = `import`; new `server_version`s from the global sequence; no pushes.
* Description is truncated to 100 code points; the full original goes into `notes` when truncated. An empty description, or one over 1000 code points (the `notes` limit, §9.6), makes the row invalid (exit `3`, nothing written) (D-046).
* Category mapping (case-insensitive exact match on the `Category` column, English and Swedish names, D-003). The importer commits this table as the data file `server/crates/kvitt-import/data/splitwise_categories.csv` (columns `splitwise_name,language,default_key`) and reads it from there; it is the only mapping. The Swedish names are those observed in the real Swedish export, each placed with its English Splitwise equivalent (D-044 added the nine found unmapped in the real run):
* Category mapping (case-insensitive exact match on the `Category` column, English and Swedish names, D-003). The importer commits this table as the data file `server/crates/kvitt-import/data/splitwise_categories.csv` (columns `splitwise_name,language,default_key`) and reads it from there; it is the only mapping. Splitwise categories are mapped as finely as the fourteen categories of AC-7.2 allow (D-053); the Swedish names are those observed in the real Swedish export, each placed with its English Splitwise equivalent (D-044, D-053):
| Splitwise category (English) | Splitwise category (Swedish) | Kvitt `default_key` |
|---|---|---|
| Groceries, Food and drink - Other, Liquor | Livsmedel, Mat och dryck - Övrigt, Alkohol | `groceries` |
| Dining out | Restaurangbesök | `restaurants` |
| Electronics, Furniture, Household supplies, Maintenance, Mortgage, Rent, Services, Pets, Home - Other | Elektronik, Möbler, Förbrukningsvaror, Skötsel/underhåll, Avbetalning/Amortering, Hyra, Tjänster, Hem - Övrigt | `home` |
| Electricity, Heat/gas, Water, Trash, TV/Phone/Internet, Cleaning, Utilities - Other | TV/telefon/internet, Städning, Verktyg - Övrigt | `utilities` |
| Rent, Mortgage | Hyra, Avbetalning/Amortering | `housing` |
| Electronics, Furniture, Household supplies, Maintenance, Services, Pets, Home - Other | Elektronik, Möbler, Förbrukningsvaror, Skötsel/underhåll, Tjänster, Husdjur, Hem - Övrigt | `home` |
| Electricity, Heat/gas, Water, Trash, TV/Phone/Internet, Cleaning, Utilities - Other | El, Värme/gas, Vatten, Sophämtning, TV/telefon/internet, Städning, Verktyg - Övrigt | `utilities` |
| Insurance | Försäkringar | `insurance` |
| Bicycle, Bus/train, Car, Gas/fuel, Parking, Taxi, Transportation - Other | Cykel, Buss/tåg, Bil, Bensin/bränsle, Parkering, Taxi, Transport - Övrigt | `transport` |
| Hotel, Plane | Hotell, Flyg | `travel` |
| Games, Movies, Music, Sports, Entertainment - Other | Spel, Musik, Filmer, Sport, Underhållning - Övrigt | `entertainment` |
| Games, Movies, Music, Sports, Entertainment - Other | Spel, Filmer, Musik, Sport, Underhållning - Övrigt | `entertainment` |
| Medical expenses | Sjukvård/medicin | `health` |
| Childcare | Barnomsorg | `children` |
| Clothing, Gifts | Kläder, Presenter | `shopping` |
| General, Childcare, Education, Insurance, Taxes, Life - Other, Other | Allmänt, Barnomsorg, Utbildning, Försäkringar, Skatter, Livet - Övrigt | `other` |
| Taxes | Skatter | `fees` |
| General, Education, Life - Other, Other | Allmänt, Utbildning, Livet - Övrigt | `other` |
| any other value | any other value | `other`, counted as unmapped |
The Swedish names El, Värme/gas, Vatten, Sophämtning and Husdjur were not seen in the export; they are Splitwise's Swedish labels by analogy (D-053). A wrong guess only leaves such a row unmapped (`other`, counted), which the summary shows.
* The summary lists each unmapped category name with its row count, and the total count of unmapped rows as a warning.
Production run (M11, D-025), in this order:
@@ -1071,9 +1124,13 @@ M0 spec ──┬─► U1 directions ─(owner picks)─► U2 design spec ─
M2c-pi infra on the Pi (Pi, TLS, Gitea, router, Drive) ◄─────┤
M10 first release ◄── M2c-pi, M8 ◄──┘
M11 import real history ◄── M9, M10
Category contract v2 (D-054), after SPEC v0.8:
M12a contracts + :domain suggestCategory (landed 6b60ef7) ─┬─► M12b server (migration 0002, importer table)
└─► M12c android (:core:model, :domain, :data, fakes) ─► M6, M7c
```
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).
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.
### 11.2 Milestone table
@@ -1090,10 +1147,13 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| M3 | Server core API | Landed `503cee5` (D-030 to D-034). E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (`src/api/rate_limit.rs`), `bootstrap`, `pair-device`, the recurring generation function of §6.3 (D-015) | Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, `next_occurrence_date`) | none | M2a |
| M4 | Server sync, audit, push, scheduler | Landed `bd6f10a` (D-035 to D-039). E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) |
| M5 | Android data layer | Landed `c28c52b` (D-026 to D-029, D-040 to D-043). DAOs on M2b's Room 3 entities and `KvittDatabase` v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b |
| 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 |
| M12a | Category contract v2: contracts | Landed `6b60ef7`. `contracts/category-keywords.csv` (148 entries), `contracts/schema/migrations/0002_categories_v2.sql` (§14.1.1), `contracts/test-vectors/suggest_category.json` (65 cases), E-1 example `schema_version` 2, `tools/contracts-check.py` (keyword-table rules, 0002 loaded on a fresh and an in-use v1 database, Python reference of §9.11, new icon and colour tokens), and `:domain`'s `suggestCategory` with `UnicodeText` passing the vectors (§14.5.6). The OpenAPI `DefaultCategoryKey` extension and the new category examples were held back for M12b | `python3 tools/contracts-check.py` passes | none | M1, SPEC v0.8 |
| M12b | Category contract v2: server | Migration `server/crates/kvitt-server/migrations/0002_categories_v2.sql` byte-identical to the contract file with its identity test; `SCHEMA_VERSION` 2; seed test of the fourteen defaults (ids, keys, icons, colours, sort orders, `server_version` from the sequence) on a fresh database and on a v1 database holding expenses (rows and references kept); icon and colour lists of §9.7 in `validate.rs`; `DefaultCategoryKey` with the fourteen keys in `contracts/openapi.yaml` (held back by M12a) and in the utoipa wire types, so the OpenAPI match test passes; the category examples M12a held back; importer table `splitwise_categories.csv` per §10.8 with its spec-parity test; importer schema check 2 (D-054); E-31 and other examples only where they enumerate the defaults | Server gate; `python3 tools/contracts-check.py` passes | none | M12a, M3, M4, M9 |
| M12c | Category contract v2: Android | `:core:model`: `DefaultCategoryKey` (14), `ColorToken` (`SLATE`, `AMBER`), `CategoryIcons.ALL` (33), `ExpenseRepository.suggestCategory`; `:data`: the `UnicodeText` binding (`java.text.Normalizer`, `Character`), `suggestCategory` from Room, DataStore and the keyword table bundled as the resource `android/data/src/main/resources/category-keywords.csv` (byte-identical to the contract file, with a test); `:core:fakes`: fourteen defaults in the empty scenario and a fake `suggestCategory`; default-name string resources for the four new categories in English and Swedish (AC-7.2) where default names already live | Android gate | none | M12a, M2b, M5 |
| 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, M12c, 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 |
| M7c | Feature: expense | `:feature:expense` screens S-7, S-8 | as M7a | none | M6 |
| M7c | Feature: expense | `:feature:expense` screens S-7, S-8, with the category suggestion of §9.11 (CS-2, CS-3, CS-7) | as M7a | none | M6, M12c |
| M7d | Feature: settle | `:feature:settle` screens S-9, S-10 | as M7a | none | M6 |
| M7e | Feature: analytics | `:feature:analytics` screens S-11, S-12 | as M7a | none | M6 |
| M7f | Feature: settings | `:feature:settings` screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 | as M7a | none | M6 |
@@ -1115,6 +1175,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
* **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 and M12c now run in parallel (disjoint paths, §14.7). 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.
* **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 and 0 are expected after it, to be confirmed by the next dry run.
---
@@ -1148,9 +1209,9 @@ A brief for one UX agent (Opus) who has read this spec and nothing else. It runs
* Category icons are Material Symbols from the set in §9.7; category colours are the tokens of §9.7 (the UX agent may change hex values, not token names, and must give light and dark values with contrast checked against both surfaces).
* Users are shown by avatar (picture or initials, AC-4.5) and display name. The viewer is always "you" / "du".
### 13.2 Navigation model (binding unless a direction proposes a better one with reasons)
### 13.2 Navigation model (binding, D-048)
* Bottom navigation with three destinations: **Home** (Översikt), **Analytics** (Statistik), **Settings** (Inställningar).
* Bottom navigation with three destinations: **Home** (Översikt), **Analytics** (Statistik), **Settings** (Inställningar). An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth destination and not taken for v1 (D-048).
* A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail.
* Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back.
* The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room).
@@ -1161,21 +1222,21 @@ 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); explanation of where the code comes from; progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code |
| 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; progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code |
| S-2 | Partner profile step | Display name field (prefilled), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob") | Continue |
| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any | 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; update banner (S-17) when available; 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 ("50 / 50" with names), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend), each expense row per AC-8.10, each settlement row per AC-11.7; archived badge if archived; pending-sync marker per row | Add expense; Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed) |
| S-5 | Account detail | Account name, default split ("50 / 50" with names), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend), each expense row per AC-8.10, each settlement row per AC-11.7; 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 | 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); 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 | Save; Cancel; Delete (edit mode) |
| 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) | 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 | 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 | Confirm; Cancel |
| 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) | Confirm; Cancel |
| S-10 | Settlement detail | Payer, recipient, amount, date, note, allocations per account; timeline | 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") | 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"); total; expense rows as in S-5 | 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 (30 icons of §9.7), colour swatches (12 tokens); delete or archive with the reason when delete is not allowed | Add; Edit; Reorder (drag); Archive; Delete |
| 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; "Changes not synced" list from `sync_rejections` with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync now" | 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) |
@@ -1226,12 +1287,12 @@ Suggested starting points the UX agent may use, replace or merge (it must delive
2. **"Skärgård"**: Material 3 Expressive with a cool sea-and-granite palette, large rounded shapes, bold balance header, playful but quiet.
3. **"Kontor"**: dense, utilitarian, data-forward. Neutral greys, strong typographic hierarchy, charts as the hero on Analytics, minimal decoration.
**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`:
**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`. The owner picked direction 3 "Kalas" with the muted palette of `docs/ux/directions/3-kalas/`, typeface per D-020 (D-047):
* `design-spec.md`: final tokens (colour roles light and dark, category colour hex values, typography scale, shapes, spacing scale in dp, elevation, motion durations and easing), component inventory (each with anatomy, states, sizes), and for every screen S-1 to S-22: layout description, all states of §13.3, interaction details, TalkBack labels.
* `strings.csv`: every user-visible string of the app with columns `key,en,sv,notes`, keys in `snake_case` prefixed by the feature module that holds them (`home_balance_owes_you`, `expense_save`; shared strings for `:core:design` never start with a feature name; the app name is the only `:app` string; §6.2, D-017), placeholders as Android format arguments (`%1$s`), plurals marked in `notes` with their quantities. This becomes `values/strings.xml` and `values-sv/strings.xml` in M6 and M7; feature agents add no strings that are not in this file without a decisions entry.
* `screens/<S-id>-<slug>.png` for every screen and state, light and dark, Swedish.
* Updated colour hex values for §9.7, proposed as a spec change (decisions entry, spec version bump) by the orchestrator.
* Updated colour hex values for the 14 tokens of §9.7 (the muted Kalas values, `slate` and `amber` included), proposed as a spec change (decisions entry, spec version bump) by the orchestrator (D-051).
### 13.5 Out of scope for the UX sprint
@@ -1243,9 +1304,9 @@ New features, changes to flows required by §4, changes to enums or limits, app
Contracts are what lets agents work in parallel. Each subsection is binding. The files that M1 writes from this section are listed in §14.8. A change to any contract follows §14.8.
### 14.1 SQLite schema v1 (server)
### 14.1 SQLite schema v1 (server) and migrations
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 **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.
```sql
-- Kvitt server schema v1. Do not edit; add a new migration and bump the schema version (SPEC §14.8).
@@ -1488,6 +1549,83 @@ Server-side rules on top of the DDL:
* No row of `users`, `devices`, `accounts`, `categories`, `recurring_templates`, `expenses`, `settlements` or `audit_log` is ever hard-deleted. Child rows (`account_default_shares`, `recurring_template_splits`, `expense_splits`, `settlement_allocations`) are replaced (delete and insert) when their parent is updated.
* `invites` rows are never deleted; `sync_mutations` rows are pruned after 90 days by a daily job at 04:00 Europe/Stockholm (DST-aware, always local 04:00) that deletes rows whose `received_at` is strictly older than 90 days; a row exactly 90 days old is kept (D-021, D-039).
#### 14.1.1 Migration 0002: schema version 2 (D-054)
Migration 0002 adds the four default categories `housing`, `insurance`, `children` and `fees` of AC-7.2 to every database, existing ones (the development stack included) on upgrade and new ones right after 0001. The SQL below is the content of the contract file `contracts/schema/migrations/0002_categories_v2.sql` as M12a committed it (`6b60ef7`; the file wins over this copy) and of the server migration `server/crates/kvitt-server/migrations/0002_categories_v2.sql`, byte for byte (a test asserts equality, as for 0001); `contracts/schema/server-v1.sql` stays unchanged. Every later migration follows the same pattern: `contracts/schema/migrations/<NNNN>_<name>.sql` is its locked text.
* The v1 `CHECK` on `categories.default_key` lists ten keys and SQLite cannot alter a `CHECK`, so the migration rebuilds `categories` with the fourteen keys (the ten of v1 followed by the four new ones) and otherwise the same columns and constraints, keeping every row unchanged (custom categories, renames, archive state, `server_version`).
* sqlx runs the migration in one transaction on a connection with `foreign_keys = ON` (§8.9), where `PRAGMA foreign_keys` cannot change. `PRAGMA defer_foreign_keys = ON` defers the checks to the commit; the rows are copied out, the table dropped and recreated under its own name, and the rows copied back, so every reference from `expenses` and `recurring_templates` resolves again before the commit.
* Each new row takes its `server_version` from the global sequence (§14.1 rules), so phones receive the four rows in their next pull like any other change. Ids `01920000-0000-7000-8000-000000000011` to `…014`, `created_at` = `updated_at` = `2026-10-18T00:00:00.000Z`, `name` null.
* `meta.schema_version` becomes `2`; the server's `SCHEMA_VERSION` constant (E-1) becomes 2, and the importer's check (§10.8), which reads that constant, follows.
* The Room schema is unchanged (version 1): category rows reach the phone through sync (§14.2).
```sql
-- Kvitt server schema v2: four new default categories (housing, insurance, children, fees). Do not edit; add a new
-- migration and bump the schema version (SPEC §14.8). Applied on top of server-v1.sql (0001_init.sql).
--
-- The default_key CHECK of categories cannot be altered in place in SQLite, so the table is rebuilt: copy the rows
-- aside, drop the table, recreate it with the new CHECK and copy the rows back, ids and server_version unchanged.
-- The migration runs inside one transaction (sqlx wraps every migration in one) with foreign_keys = ON (SPEC §8.9).
-- defer_foreign_keys = ON lets expenses and recurring_templates point at categories while the table is briefly
-- absent; the deferred check at commit still fails the migration if any reference is left dangling. It resets
-- itself when the transaction ends. No other table, index or trigger refers to categories by anything but name.
PRAGMA defer_foreign_keys = ON;
CREATE TABLE categories_v1_copy AS SELECT * FROM categories;
DROP TABLE categories;
CREATE TABLE categories (
id TEXT PRIMARY KEY NOT NULL,
default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN
('groceries', 'restaurants', 'home', 'utilities', 'transport',
'travel', 'entertainment', 'health', 'shopping', 'other',
'housing', 'insurance', 'children', 'fees')),
name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40),
icon TEXT NOT NULL,
color TEXT NOT NULL,
sort_order INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT,
server_version INTEGER NOT NULL UNIQUE,
CHECK (name IS NOT NULL OR default_key IS NOT NULL)
) STRICT;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version
FROM categories_v1_copy;
DROP TABLE categories_v1_copy;
-- Each new row takes the next server_version from the global sequence, as every server write does (SPEC §14.1), so
-- phones receive the rows on their next pull whatever their cursor.
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000011', 'housing', NULL, 'key', 'indigo', 25,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000012', 'insurance', NULL, 'shield', 'slate', 45,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000013', 'children', NULL, 'child_care', 'lime', 85,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000014', 'fees', NULL, 'account_balance', 'amber', 95,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE meta SET value = '2' 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).
@@ -1556,15 +1694,15 @@ enum class InviteKind { ADMIN_SETUP, PARTNER, DEVICE }
enum class EntityType { EXPENSE, SETTLEMENT }
enum class AuditAction { CREATE, UPDATE, DELETE }
enum class AuditSource { APP, RECURRING, IMPORT }
enum class DefaultCategoryKey { GROCERIES, RESTAURANTS, HOME, UTILITIES, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, SHOPPING, OTHER }
enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME }
enum class DefaultCategoryKey { GROCERIES, RESTAURANTS, HOUSING, HOME, UTILITIES, INSURANCE, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, CHILDREN, SHOPPING, FEES, OTHER } // AC-7.2 order (D-054)
enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME, SLATE, AMBER }
enum class RangePreset { THIS_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_12_MONTHS, THIS_YEAR, ALL_TIME, CUSTOM }
enum class Granularity { MONTH, WEEK }
enum class Measure { HOUSEHOLD, MY_SHARE }
enum class GroupBy { CATEGORY, ACCOUNT }
enum class SyncRejectionReason { STALE, DELETED, VALIDATION_FAILED, REFERENCE_NOT_FOUND, CLOCK_SKEW }
// Wire value of every enum = lowercase name (SplitMode.DEFAULT <-> "default"). Icon tokens are Strings from CategoryIcons.ALL (§9.7).
object CategoryIcons { val ALL: List<String> /* the 30 tokens of §9.7, in that order */ }
object CategoryIcons { val ALL: List<String> /* the 33 tokens of §9.7, in that order */ }
// Plain records
data class User(val id: UserId, val role: Role, val displayName: String, val pictureVersion: Int?)
@@ -1643,6 +1781,7 @@ interface ExpenseRepository {
suspend fun save(draft: ExpenseDraft): SaveResult<ExpenseId>
suspend fun delete(id: ExpenseId)
fun detail(id: ExpenseId): Flow<ExpenseDetailState?>
suspend fun suggestCategory(accountId: AccountId?, description: String): CategoryId? // §9.11, D-049; null only while the phone holds no categories
}
// Settlements (S-9, S-10)
@@ -1737,6 +1876,12 @@ 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.
Changes in v0.8 (D-049, D-054):
* **Category suggestion types** live in `:domain`, not here (§14.5.6); `:core:model` gains only `ExpenseRepository.suggestCategory`.
* **Fourteen defaults.** `DefaultCategoryKey` gains `HOUSING`, `INSURANCE`, `CHILDREN`, `FEES` (wire values `housing`, `insurance`, `children`, `fees`), `ColorToken` gains `SLATE` and `AMBER`, `CategoryIcons.ALL` gains `key`, `shield`, `account_balance` at the end. The localised default names are those of AC-7.2.
* **`ExpenseRepository.suggestCategory`.** Computed in `:data` from Room (every non-deleted expense on the phone, pending ones included, and the active categories), the per-phone last-used category of the account (DataStore, §14.2.2) and the keyword table bundled as the resource `android/data/src/main/resources/category-keywords.csv`, by calling `:domain`'s `suggestCategory` (§14.5.6). `:data` binds `:domain`'s `UnicodeText` with `java.text.Normalizer` and `Character`, since `:domain` may not use `java.*` (§14.5.6). `accountId` null (no active account) gives the fallback `other`. The result is null only while the phone holds no categories (before the first sync). The editor (S-7) calls it on every change of the description and of the account while the category was not picked by hand (§9.11 CS-2, CS-3) and never when editing an existing expense (CS-1). `ExpenseEditorOptions.defaultCategoryFor` stays: it is the step-4 default before any description is typed.
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).
@@ -1824,8 +1969,8 @@ All server-produced objects. `?` marks nullable (always present). Types: `string
"created_at": ts, "updated_at": ts, "archived_at": ts?, "deleted_at": ts?, "server_version": int }
// Category
{ "id": uuid, "default_key": "groceries" | "restaurants" | "home" | "utilities" | "transport" | "travel" |
"entertainment" | "health" | "shopping" | "other" | null, "name": string?, "icon": string, "color": string,
{ "id": uuid, "default_key": "groceries" | "restaurants" | "housing" | "home" | "utilities" | "insurance" | "transport" |
"travel" | "entertainment" | "health" | "children" | "shopping" | "fees" | "other" | null, "name": string?, "icon": string, "color": string,
"sort_order": int, "created_at": ts, "updated_at": ts, "archived_at": ts?, "deleted_at": ts?, "server_version": int }
// Split (of an expense): share_bp null for mode exact
@@ -1871,7 +2016,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": 1, "db_ok": true }
{ "status": "ok", "server_version": "0.1.0", "schema_version": 2, "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).
@@ -2149,7 +2294,7 @@ With these values a phone offline for 14 days loses nothing: tokens do not expir
### 14.5 Domain rules as pure functions
Implemented twice: Kotlin in `:domain` (package `se.hagstedt.kvitt.domain`, input and output types in `:core:model` package `se.hagstedt.kvitt.model.money`) and Rust in `kvitt-domain`. Both are pure (no I/O, no clock, no randomness, no locale) and must pass every vector in `contracts/test-vectors/`. Analytics exists only in Kotlin; `next_occurrence_date` and the day clamping exist only in Rust (D-015). The rules themselves are §9.1, §9.3 and §9.4; this section fixes signatures, error tokens and the vector format.
Implemented twice: Kotlin in `:domain` (package `se.hagstedt.kvitt.domain`, input and output types in `:core:model` package `se.hagstedt.kvitt.model.money`) and Rust in `kvitt-domain`. Both are pure (no I/O, no clock, no randomness, no locale) and must pass every vector in `contracts/test-vectors/`. Analytics and the category suggestion (§14.5.6) exist only in Kotlin; `next_occurrence_date` and the day clamping exist only in Rust (D-015). The rules themselves are §9.1, §9.3 and §9.4; this section fixes signatures, error tokens and the vector format.
#### 14.5.1 Types
@@ -2253,7 +2398,7 @@ fun resolveRange(preset: RangePreset, today: LocalDate, earliestExpense: LocalDa
#### 14.5.5 Test-vector format
One file per function in `contracts/test-vectors/`: `compute_split.json`, `compute_balances.json`, `propose_allocation.json`, `validate_settlement.json`, `over_settled_accounts.json`, `spend_series.json` (Kotlin only), `resolve_range.json` (Kotlin only).
One file per function in `contracts/test-vectors/`: `compute_split.json`, `compute_balances.json`, `propose_allocation.json`, `validate_settlement.json`, `over_settled_accounts.json`, `spend_series.json` (Kotlin only), `resolve_range.json` (Kotlin only), `suggest_category.json` (Kotlin only, D-049).
```json
{
@@ -2280,7 +2425,7 @@ One file per function in `contracts/test-vectors/`: `compute_split.json`, `compu
}
```
Input keys are the snake_case parameter names of the Rust signatures. For the Kotlin-only analytics functions they are the snake_case names of the Kotlin parameters and fields: `spend_series` takes `expenses` (`[{"date", "account_id", "category_id", "amount_ore", "shares"}]`), `range` (`{"start", "end_inclusive"}`), `granularity`, `measure`, `group_by`; `resolve_range` takes `preset`, `today`, `earliest_expense`, `custom_start`, `custom_end`. `spec` is `{"mode":"ratio","bp":{…}}`, `{"mode":"exact","ore":{…}}` or `{"mode":"full","bearer_user_id":"…"}`. Outputs: `compute_split` `{"ok": {user: öre}}`; `compute_balances` `{"ok": {"per_account": {...}, "global": {...}}}`; `propose_allocation` `{"ok": {"allocations": [{"account_id","amount_ore"}], "excess_ore": n, "over_settled_account_ids": [...]}}`; `validate_settlement` `{"ok": null}`; `over_settled_accounts` `{"ok": [ids]}`; `spend_series` `{"ok": {"buckets": [{"key","start","end_inclusive","totals","total"}], "group_totals": {...}, "grand_total": n}}` with `measure` input `"household"` or `{"share": "a"}`, and dates as strings; `resolve_range` `{"ok": {"start","end_inclusive"}}`. Errors: `{"error": "<token>"}`.
Input keys are the snake_case parameter names of the Rust signatures. For the Kotlin-only analytics functions they are the snake_case names of the Kotlin parameters and fields: `spend_series` takes `expenses` (`[{"date", "account_id", "category_id", "amount_ore", "shares"}]`), `range` (`{"start", "end_inclusive"}`), `granularity`, `measure`, `group_by`; `resolve_range` takes `preset`, `today`, `earliest_expense`, `custom_start`, `custom_end`; `suggest_category` takes `description` (string), `history` (`[{"description", "category_id", "expense_date", "created_at"}]`), `keywords` (`[{"keyword", "match", "default_key"}]` in table order, `match` one of `token`, `prefix`, `phrase`), `active_categories` (`[{"id", "default_key"}]`, `default_key` null for a custom category) and `fallback` (category id or null), exactly as in `contracts/test-vectors/suggest_category.json` (`6b60ef7`); the `unicode` parameter has no key (the test binds the platform implementation). `spec` is `{"mode":"ratio","bp":{…}}`, `{"mode":"exact","ore":{…}}` or `{"mode":"full","bearer_user_id":"…"}`. Outputs: `compute_split` `{"ok": {user: öre}}`; `compute_balances` `{"ok": {"per_account": {...}, "global": {...}}}`; `propose_allocation` `{"ok": {"allocations": [{"account_id","amount_ore"}], "excess_ore": n, "over_settled_account_ids": [...]}}`; `validate_settlement` `{"ok": null}`; `over_settled_accounts` `{"ok": [ids]}`; `spend_series` `{"ok": {"buckets": [{"key","start","end_inclusive","totals","total"}], "group_totals": {...}, "grand_total": n}}` with `measure` input `"household"` or `{"share": "a"}`, and dates as strings; `resolve_range` `{"ok": {"start","end_inclusive"}}`; `suggest_category` `{"ok": "<category id>"}` or `{"ok": null}` (it has no error case). Errors: `{"error": "<token>"}`.
Minimum cases M1 must include (more are welcome):
@@ -2291,6 +2436,34 @@ Minimum cases M1 must include (more are welcome):
* `over_settled_accounts`: none, one, an unknown account.
* `spend_series`: monthly and weekly over a range crossing a year boundary (ISO week 2026-W53 exists: 28 Dec 2026 to 3 Jan 2027), household and share measures, category and account grouping, empty buckets, edge-clipped buckets.
* `resolve_range`: every preset with `today` = `2026-10-17`, `ALL_TIME` with and without an earliest expense, `CUSTOM` swapped.
* `suggest_category` (M12a): empty and punctuation-only descriptions (fallback); exact history match across case, `'`, `’`, `:` and other punctuation ("Willy:s" and "WILLYS"); most often beats most recent; ties broken by `expense_date`, then `created_at`, then category id; first-token match when no exact match; exact match taking precedence over first token; an archived category in history skipped (the next category, then the next step); `token`, `prefix` and `phrase` keywords; longest keyword wins; equal length, earlier entry wins; a keyword whose category is archived falls through to the next matching entry; a renamed default category found by its key; diacritics kept (`entre` and `entré` distinct); `&` and digits kept (`h&m`, `okq8`); NFC applied to non-NFC input; surrogate pairs counted as one code point; no match gives `fallback`; `fallback` null gives null. M12a's file has 65 cases.
#### 14.5.6 Category suggestion (Kotlin only)
The rule is §9.11 (D-049). Types and functions in `:domain`, package `se.hagstedt.kvitt.domain` (landed with M12a, `6b60ef7`). The records live in `:domain`, not in `:core:model`: the keyword table is data, not a UI contract, and default keys are open strings there.
```kotlin
data class SuggestionHistoryEntry(val description: String, val categoryId: CategoryId, val expenseDate: LocalDate, val createdAt: String)
enum class KeywordMatch { TOKEN, PREFIX, PHRASE }
data class CategoryKeyword(val keyword: String, val match: KeywordMatch, val defaultKey: String)
data class ActiveCategory(val id: CategoryId, val defaultKey: String?)
interface UnicodeText {
fun nfc(text: String): String
fun isLetter(codePoint: Int): Boolean // general category Lu, Ll, Lt, Lm or Lo
fun isDigit(codePoint: Int): Boolean // general category Nd
}
fun normalizeDescription(text: String, unicode: UnicodeText): String // CS-4
fun suggestCategory(description: String, history: List<SuggestionHistoryEntry>, keywords: List<CategoryKeyword>, activeCategories: List<ActiveCategory>, fallback: CategoryId?, unicode: UnicodeText): CategoryId?
```
(`CategoryId` is `se.hagstedt.kvitt.model.CategoryId`; `LocalDate` is `kotlinx.datetime.LocalDate`.)
* `UnicodeText` exists because pure modules may not use `java.*` and `kotlin.*` has neither NFC nor general categories. `:data` binds it with `java.text.Normalizer` (NFC) and `java.lang.Character` (`getType` for L and Nd); the `:domain` tests bind the same. `normalizeDescription` applies `nfc`, then `lowercase()`, then walks the string by code points (a surrogate pair is one code point), deleting `'`, `’` and `:`, keeping `&`, letters and digits, and turning every other code point into a space; then it collapses spaces and trims. Inputs need not be NFC; the vectors include non-NFC strings.
* `history` holds non-deleted expenses only (the caller's job, as for every domain function). Expenses whose `categoryId` is not in `activeCategories` are ignored (CS-5).
* History ranking (CS-5 steps 1 and 2): the highest count wins; on equal count, the category whose latest matching expense is latest by (`expenseDate`, then `createdAt`); then the lower category id (string order).
* Keyword ranking (CS-6): among the matching entries, the longest keyword in code points wins, then the earlier entry in table order (`keywords` are passed in table order, §14.8). An entry resolves to the active category with that `defaultKey`; none means the next entry in that ranking is tried.
* `fallback` is the step-4 category id (AC-8.6), computed by the caller; it is returned as given when no earlier step applies, also when the normalised description is empty.
### 14.6 Push payload v1
@@ -2350,7 +2523,8 @@ SPEC.md CLAUDE.md README-agents.md PLAN-*.md
.gitea/workflows/ (Gitea Actions image build, M2c-pi)
contracts/
openapi.yaml
schema/server-v1.sql
category-keywords.csv
schema/server-v1.sql schema/migrations/0002_categories_v2.sql
test-vectors/*.json
examples/<E-id>-<name>/{request,response}.json, examples/errors/<code>.json, examples/sync/*.json, examples/push/*.json
docs/ HANDOFF.md decisions.md ux/ spikes/ validation/
@@ -2359,7 +2533,7 @@ tools/ gradle-serial.sh clean-builds.sh install-git-hooks.sh git-hooks/ dep
server/
Cargo.toml (workspace) Cargo.lock Dockerfile rust-toolchain.toml
crates/kvitt-domain/ src/{lib.rs, split.rs, balance.rs, allocation.rs, settlement.rs, recurring.rs} tests/vectors.rs
crates/kvitt-server/ migrations/0001_init.sql
crates/kvitt-server/ migrations/{0001_init.sql, 0002_categories_v2.sql}
src/{main.rs, lib.rs, config.rs, error.rs, router.rs, openapi.rs, auth.rs, time.rs}
src/db/{mod.rs, pool.rs, sequence.rs}
src/wire/{mod.rs, user.rs, device.rs, invite.rs, account.rs, category.rs, expense.rs, settlement.rs, recurring.rs, audit.rs, sync.rs}
@@ -2407,6 +2581,9 @@ Ownership (an agent edits only paths its milestone owns; anything else needs the
| `.gitea/workflows/**` | M2c-pi (D-023) | later infra plans |
| git-ignored `infra/secrets/**` in the main checkout | M2c-dev (D-022), then M2c-pi | M8 copies `google-services.json` from it (§10.7) |
| `docs/ux/**` | U1, U2 | |
| M12 (D-054): `contracts/category-keywords.csv`, `contracts/schema/migrations/**`, `contracts/test-vectors/suggest_category.json` and the other `contracts/**` edits of §11.2 row M12a, `tools/contracts-check.py`, `android/domain/**` (`suggestCategory`, `UnicodeText`) | M12a (contract-change plan, landed `6b60ef7`) | contract-change plans only |
| M12 (D-054): `contracts/openapi.yaml` (`DefaultCategoryKey`) and the category examples M12a held back; `kvitt-server` `migrations/0002_categories_v2.sql`, `src/db/mod.rs` (`SCHEMA_VERSION`), `src/api/validate.rs` (token lists), `src/wire/category.rs` and `src/wire/mod.rs` (key enum), their tests; `kvitt-import` `data/splitwise_categories.csv`, `src/categories.rs` and its tests | M12b (contract-change plan) | the usual owners |
| M12 (D-054): `android/core/model/**` (enums, icons, `ExpenseRepository`), `android/data/**` (`UnicodeText` binding, `suggestCategory`, the bundled keyword resource), `android/core/fakes/**` (defaults, fake `suggestCategory`) | M12c (contract-change plan) | the usual owners |
| `docs/validation/<file>` | the milestone that names the file | |
Module shells (D-004): a shell file or module created by M2a or M2b in a path owned by a later milestone holds only what makes the build and the 501 stubs work; the later milestone owns it from the moment the shell lands and writes the body.
@@ -2415,7 +2592,9 @@ 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` and any later `server-vN.sql`, `contracts/test-vectors/*.json`, `contracts/examples/**`, the Room exported schema `android/data/schemas/**` (version 1 exists since M2b, D-012), `android/core/model/**` (the UI contract and domain types), the sync protocol (§14.4) and the push payload (§14.6).
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).
`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 a byte-identical copy (`android/data/src/main/resources/category-keywords.csv`, §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.
Representational edits to `contracts/openapi.yaml` (key order, `$ref` naming, description wording) do not change meaning and need no spec bump or decision entry; they are listed in the report of the plan that made them (D-016). The OpenAPI equality test compares parsed values, not text.
@@ -2432,7 +2611,7 @@ The equality test (`server/crates/kvitt-server/tests/openapi_contract.rs`, D-005
The document-wide rules of §14.3.1 and §14.3.2 live in one place on the generated side, the `CommonRules` modifier in `server/crates/kvitt-server/src/openapi.rs`, never in a handler annotation: the common request headers and the `X-Kvitt-Server-Version` response header on every operation, the always-possible error responses of §14.3.2 (authenticated and unauthenticated sets), the document-level `device` security, and response headers marked required.
Rule: a change to any other contract file, or a semantic change to `contracts/openapi.yaml` or to §14 of this spec, requires, landed together by the orchestrator: a spec version bump (in the header and the change log), a `docs/decisions.md` entry, and the matching implementation changes on both sides, or a plan that schedules them. A schema change also bumps the schema version (`meta.schema_version` and a new migration on the server; a new Room version and migration on the phone). A sync protocol change bumps `X-Kvitt-Protocol`; a push payload change bumps `v`. Numbers come from the orchestrator's brief. Implementation agents never edit `SPEC.md` or `docs/decisions.md`: they put the proposed spec text and decision entry in their report, and the orchestrator has a docs agent record them (CLAUDE.md › Orchestration).
Rule: a change to any other contract file, or a semantic change to `contracts/openapi.yaml` or to §14 of this spec, requires, landed together by the orchestrator: a spec version bump (in the header and the change log), a `docs/decisions.md` entry, and the matching implementation changes on both sides, or a plan that schedules them. A schema change also bumps the schema version (`meta.schema_version` and a new migration on the server, 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).
---
@@ -2447,3 +2626,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 0.5 | 2026-10-17 | Decisions from the M3 (`503cee5`) and M5 (`c28c52b`) implementation reports. M3: D-030 picture version after a removal, `-removed` marker, next version = highest on disk + 1, first upload 1 (AC-4.4, §10.4, E-10); D-031 failed accept = any 4xx but 429, refused until 60 minutes after the first failure, fixed windows (AC-2.12); D-032 rate-limit keying by token hash, 401 counts against the IP, never-accepted tokens refused when the IP is over, `KVITT_TRUSTED_PROXY` re-resolved every 60 s, last `X-Forwarded-For` entry, `into_make_service_with_connect_info` added by M4 (§8.6, §10.4, §11.3); D-033 resume moves `last_generated_period` only to a period at or after `start_period` (§9.5); D-034 strings stored trimmed, transparency flattened onto white, `fcm_token` non-empty or null, revoke clears `fcm_token`, idempotent archive keeps `server_version`, new category `sort_order` over non-deleted (§8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26). M5 (renumbered: its D-030 to D-033 are D-040 to D-043): D-026 `cursor_ahead` is epoch recovery (§14.4.2); D-027 `clock_skew` gives a new `pending_mutation_id`, and the phone re-stamps `updated_at` on the next attempt (orchestrator decision, follow-up open) (§14.4.5); D-028 periodic work `kvitt-sync-periodic` (§14.4.1); D-029 `OnlineResult.Failed("network", null)` (§14.2.4); D-040 whole-request push refusals count as no progress, 429 retries, 426 stops (§14.4.5); D-041 `DataBindingsModule` (§6.2); D-042 Room tests on Robolectric, migrations from the exported JSON (§6.2); D-043 pairing status and 426 version code in DataStore (§8.2, §14.2.2). D-035 to D-039 reserved for M4. Clarifications without a decision id: control characters restricted only in `notes`, newlines accepted in names and descriptions (§8.6); landing page without download button while no APK is published, 404 page, `Referrer-Policy` and `Cache-Control` headers (AC-2.5, §13.3); generation sets the template's `updated_at` (§9.5); E-28 accepts archived accounts and categories (AC-6.4); E-14 on an accepted invite is 204 with no change; `/health` from the healthcheck counts against loopback (§8.6); M3's tests in one binary `tests/core_api/` (§14.7); sync triggers, batch and page sizes (AC-15.3, §14.4.1); 426 stops syncing until the installed version changes and `HomeState` has no update-required field (§14.2.4, §14.3.1); pictures always fetched at 512; `NeedsInstallPermission` once (AC-16.3); `Rejection.summary`, `FieldChange` display and `RecurringTemplate.draft.date` as documented in `:data` (§14.2.4); M8 contract requests (§11.3). |
| 0.6 | 2026-10-18 | Decisions from the M4 (`bd6f10a`) implementation report. D-035: a mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable fails the whole request with 400 `invalid_request`; every other shape error is a per-mutation `validation_failed` (§14.4.4 step 3). D-036: `POST /sync` with 0 mutations is 422 `batch_too_large`, the code now reads "outside 1 to 100" (§9.6, §14.3.2, §14.4.4). D-037: a delete of a never-synced row runs the full step-8 validation; every rejection carries the stored row or null as `current`, also at steps 3 and 4; step 11 keeps the stored `created_at`; pushed ids must be canonical lowercase hyphenated UUIDs (§14.4.3, §14.4.4). D-038: FCM retries are one send plus up to three retries at 1 s, 5 s, 25 s; 401 drops the cached access token and is not retried, other 4xx are not retried; retries block the single sender task up to 31 s per device (§14.6). D-039: pruning deletes `received_at` strictly older than 90 days; seam change to D-015: `tasks::start` returns the `AppState` with the real push queue and `serve` builds the router from it, `AppState::new` defaults to `SqlAuditWriter`, `NoopAuditWriter` removed, `NoopPushQueue` kept for push disabled (§6.3, §14.1, §14.4.9; PLAN-M4). Clarifications without a decision id: §14.4.4 step 8 order is field rules, domain rules, references; a negative `since` is `invalid_request` and a pull reads one page in a single read transaction (§9.6, §14.3.4, §14.4.2); push runs in one `BEGIN IMMEDIATE` with a savepoint per mutation; the push queue is bounded at 100 and drops the oldest with a warning; the FCM JWT is RS256 with `kid` = `private_key_id`, the access token cached until 5 minutes before expiry, messages data-only with string values; `UNREGISTERED` or 404 clears the device token only if unchanged; an unusable credentials file disables push like a missing one (§6.3, §14.6); the scheduler reads the injected clock at least every 30 s, runs a missed job once after a forward jump and re-plans after a backward jump of more than a day (§9.5); `rotate-epoch` prints the new epoch, exit 0 or 1 on a database error (§10.4); `serve` uses `into_make_service_with_connect_info` (D-032 fulfilled, §8.6, §11.3); `GET /i/` with an empty code is the HTML 404 (E-4); reqwest (rustls, aws-lc-rs, platform verifier) and jsonwebtoken added, so the arm64 image needs a C toolchain stage and the distroless image CA roots, to be confirmed by M2c-pi or M10; the dev image was rebuilt with them on the Mac on 2026-10-18 (§6.3, §10.3); M4's test binaries and scheduler path (§11.3, §14.7). |
| 0.7 | 2026-10-18 | Decisions from the M9 (`ab298ab`) implementation report and the importer rehearsal on the real exports. D-044: nine Swedish Splitwise category names added to the §10.8 table and to `splitwise_categories.csv` with the key of their English equivalent (Städning and Verktyg - Övrigt `utilities`, Tjänster `home`, Cykel `transport`, Flyg `travel`, Spel `entertainment`, Kläder `shopping`, Livet - Övrigt and Utbildning `other`). D-045: reconciliation: the run commits only if each user's balance of the whole account equals the sum of the file's totals rows (one per currency, converted with `--fx`); a mismatch or a missing totals row rolls back and exits 4, a dry run reports the same, an account already holding other rows rolls back, a mismatch after any `--fx` conversion is reported but accepted (§10.8, AC-17.5). D-046: CLI semantics: `--user` takes a user id or an exact unique display name; no database is created or migrated, `meta.schema_version` must be 1; a dry run runs the full transaction and rolls back; exit codes 0 ok, 1 database or file error, 2 usage, 3 cannot import, 4 reconciliation mismatch; the import key uses an existing account's stored name; an empty description or one over 1000 code points makes the row invalid; descriptions over 100 code points are cut with the full text in `notes`; settlement notes cut to 200; both nets zero is mode `full` on the owner; idempotency through the existing `import_key` columns (§10.8, AC-17.5). M9 marked landed, M11 rehearsal recorded (§11.2, §11.3). |
| 0.8 | 2026-10-18 | Decisions from the owner's UX session (`docs/ux/proposed-decisions.md` UX-1 to UX-8 on branch `UX-sprint`). D-047: direction 3 "Kalas" with the muted palette, typeface per D-020 (§9.7, §13.4). D-048: navigation stays at three destinations, an "Aktivitet" tab not taken for v1; §13.2 binding. D-049: category suggestion from the description, new §9.11 (CS-1 to CS-7: new expenses only, manual choice wins, history most often then first token, keyword table, AC-8.6 default, no marker), AC-8.6, `ExpenseRepository.suggestCategory` (§14.2.4), Kotlin-only `suggestCategory` in `:domain` with the `UnicodeText` interface bound by `:data` and `suggest_category.json` as landed by M12a (`6b60ef7`) (§14.5.5, §14.5.6), contract file `contracts/category-keywords.csv` (§14.8). D-050: S-1 display-name field on a first pairing (AC-2.4, §13.3). D-051: §9.7 hex values become the muted Kalas values from U2, token names fixed, `slate` and `amber` `TODO(U2)`. D-052: "Utgiften sparades" without undo (S-7), archived account hides the FAB (S-5), per-account settle-up keeps the swap and an editable amount (S-9), avatar tokens `avatar-1` and `avatar-2` (AC-4.5), Swedish effect copy (AC-8.10). D-053: finer Splitwise mapping (§10.8). D-054: fourteen default categories (`housing`, `insurance`, `children`, `fees` added; AC-7.2), icons `key`, `shield`, `account_balance` (33), colours `slate`, `amber` (14), server schema 2 through migration 0002 rebuilding `categories` (§14.1.1, locked as `contracts/schema/migrations/0002_categories_v2.sql`, copied from M12a's file; no `server-v2.sql`, §14.8), importer schema check 2 (§10.8), enums in §14.2.4 and §14.3.3, E-1 `schema_version` 2; Room schema unchanged. New milestone group M12 "Category contract v2" (M12a contracts, then M12b server and M12c Android in parallel; M6 and M7c wait for M12c) (§11). |