SPEC v0.9: M12b and M12c outcomes (D-055 to D-058), U2 values, UX-9 (D-059, M12d)
- D-055 build-time copy of the keyword table, D-056 parseCategoryKeywords, D-057 enum declaration orders, D-058 E-7 example shows server_version 15 and 16 and its test compares them again. - D-051 fulfilled: §9.7 hex values from docs/ux/design-spec.md §2.3; design-spec.md binding, strings.csv the source of string resources. - D-059: ExpenseEditorOptions.frequentCategoryIds and CategoryRepository.deletable; new milestone M12d with PLAN-M12d and prompts/M12d.md; M7a-f depend on M6 and M12d. - suggestCategory readings (null when other is archived), §11.2 landed marks and the 2026-10-18 dry run, dev stack on schema 2, PLAN-M12b SQLite 3.51.x, design-spec §6 notes in PLAN-M6/M7c/M7f; HANDOFF rewritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Kvitt: specification
|
||||
|
||||
Version **0.8** (2026-10-18). Schema version **2**. Sync protocol version **1**. Push payload version **1**.
|
||||
Version **0.9** (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.
|
||||
|
||||
@@ -197,7 +197,7 @@ 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 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.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); when `other` is archived too and no active last-used category exists, the editor shows no category until the user picks one (v0.9); 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.
|
||||
@@ -749,24 +749,24 @@ Category icon tokens are Material Symbols names. The allowed set (exhaustive, 33
|
||||
|
||||
`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, 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).
|
||||
Colour tokens (exhaustive, 14 tokens; `slate` and `amber` added by D-054) with the muted Kalas values of `docs/ux/design-spec.md` §2.3 (D-047, D-051; recorded in v0.9). The table holds the base colour of each token; `design-spec.md` §2.3 also gives each token's container (the colour over `surface` at 18 % in light, 24 % in dark) and the contrast ratios, all at least 3:1 as graphics. Category colours are never used for text.
|
||||
|
||||
| Token | Light | Dark |
|
||||
|---|---|---|
|
||||
| `green` | `#2E7D32` | `#81C784` |
|
||||
| `orange` | `#E65100` | `#FFB74D` |
|
||||
| `brown` | `#6D4C41` | `#BCAAA4` |
|
||||
| `yellow` | `#F9A825` | `#FFF176` |
|
||||
| `blue` | `#1565C0` | `#90CAF9` |
|
||||
| `teal` | `#00796B` | `#80CBC4` |
|
||||
| `purple` | `#6A1B9A` | `#CE93D8` |
|
||||
| `red` | `#C62828` | `#EF9A9A` |
|
||||
| `pink` | `#AD1457` | `#F48FB1` |
|
||||
| `grey` | `#546E7A` | `#B0BEC5` |
|
||||
| `indigo` | `#283593` | `#9FA8DA` |
|
||||
| `lime` | `#827717` | `#DCE775` |
|
||||
| `slate` | `TODO(U2)` | `TODO(U2)` |
|
||||
| `amber` | `TODO(U2)` | `TODO(U2)` |
|
||||
| `green` | `#4E7A55` | `#8FB896` |
|
||||
| `orange` | `#B26A3E` | `#E8AA7E` |
|
||||
| `brown` | `#6B5244` | `#B8A090` |
|
||||
| `yellow` | `#9C7C2A` | `#E6C67A` |
|
||||
| `blue` | `#4A78A8` | `#98C0E6` |
|
||||
| `teal` | `#367F82` | `#8CCAC8` |
|
||||
| `purple` | `#865891` | `#C59CDA` |
|
||||
| `red` | `#A84B45` | `#DC8F86` |
|
||||
| `pink` | `#AE5D7C` | `#EFB0BE` |
|
||||
| `grey` | `#6C7177` | `#B0B2B3` |
|
||||
| `indigo` | `#45508D` | `#969DD8` |
|
||||
| `lime` | `#73822F` | `#B9CC86` |
|
||||
| `slate` | `#5E2D4E` | `#B0719A` |
|
||||
| `amber` | `#937D63` | `#DFCFC0` |
|
||||
|
||||
The server validates icon and colour against these lists (`validation_failed`).
|
||||
|
||||
@@ -829,7 +829,7 @@ Diacritics are kept (å, ä, ö and é stay distinct; the keyword table lists bo
|
||||
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`.
|
||||
4. **Default:** the AC-8.6 fallback: the category last used in the selected account on this phone if still active, else `other`; when `other` is archived too, no category (AC-8.6).
|
||||
|
||||
"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).
|
||||
|
||||
@@ -851,7 +851,7 @@ Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expe
|
||||
|
||||
All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `infra/secrets/` on the Mac (git-ignored) and in `/opt/kvitt/secrets/` on the Pi; neither is ever committed.
|
||||
|
||||
**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts the whole `infra/secrets/` folder read-only at `/secrets` instead of a single key file (a missing `fcm-service-account.json` means push is disabled, §10.4); its env file is `infra/dev/.env.example`; its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env file stays `infra/env.example`, written by M2c-pi. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10.
|
||||
**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts the whole `infra/secrets/` folder read-only at `/secrets` instead of a single key file (a missing `fcm-service-account.json` means push is disabled, §10.4); its env file is `infra/dev/.env.example`; its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env file stays `infra/env.example`, written by M2c-pi. The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10.
|
||||
|
||||
### 10.1 Hosts and network
|
||||
|
||||
@@ -1126,19 +1126,20 @@ M0 spec ──┬─► U1 directions ─(owner picks)─► U2 design spec ─
|
||||
M11 import real history ◄── M9, M10
|
||||
|
||||
Category contract v2 (D-054), after SPEC v0.8:
|
||||
M12a contracts + :domain suggestCategory (landed 6b60ef7) ─┬─► M12b server (migration 0002, importer table)
|
||||
└─► M12c android (:core:model, :domain, :data, fakes) ─► M6, M7c
|
||||
M12a contracts + :domain suggestCategory (landed 6b60ef7) ─┬─► M12b server (migration 0002, importer table; landed 2e2ebe7)
|
||||
└─► M12c android (:core:model, :domain, :data, fakes; landed e499107) ─► M6, M7c
|
||||
M12d UI contract additions (D-059: frequentCategoryIds, deletable) ─► M7a..M7f (runs in parallel with M6)
|
||||
```
|
||||
|
||||
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.
|
||||
After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: `6b60ef7`, `2e2ebe7`, `e499107`). M12d (D-059) runs in parallel with M6; M7a to M7f wait for both.
|
||||
|
||||
### 11.2 Milestone table
|
||||
|
||||
| Id | Name | Deliverable | Gate | HUMAN | Depends on |
|
||||
|---|---|---|---|---|---|
|
||||
| M0 | Spec v0.1 | `SPEC.md`, filled `CLAUDE.md`, repository bootstrap | none (docs only) | Review the spec; answer §12 | none |
|
||||
| U1 | UX directions | §13.4 phase 1 files in `docs/ux/` | Files exist and open; each direction shows the six phase-1 screens of §13.4 (S-4, S-7, S-5, S-9, S-11, S-8) in light and dark; Swedish copy, no placeholder text | Review 2-3 directions, pick one, give comments | M0 |
|
||||
| U2 | UX design spec | §13.4 phase 2 files in `docs/ux/` | Every string key has `en` and `sv`; every screen has every state that `design-spec.md` lists for it (§13.3) | Approve the design spec | U1 and the owner's pick |
|
||||
| U1 | UX directions | Landed `16c4d01` (direction 3 "Kalas" muted picked, D-047). §13.4 phase 1 files in `docs/ux/` | Files exist and open; each direction shows the six phase-1 screens of §13.4 (S-4, S-7, S-5, S-9, S-11, S-8) in light and dark; Swedish copy, no placeholder text | Review 2-3 directions, pick one, give comments | M0 |
|
||||
| U2 | UX design spec | Landed `16c4d01`, approved by the owner: `docs/ux/design-spec.md` (binding, §13.4), `strings.csv` (558 rows), `screens/`; UX-9 (D-059). §13.4 phase 2 files in `docs/ux/` | Every string key has `en` and `sv`; every screen has every state that `design-spec.md` lists for it (§13.3) | Approve the design spec | U1 and the owner's pick |
|
||||
| M1 | Contracts and vectors | `contracts/openapi.yaml`, `contracts/schema/server-v1.sql`, `contracts/test-vectors/*.json`, `contracts/examples/**/*.json`, `tools/contracts-check.py` | `python3 tools/contracts-check.py` passes: all JSON parses, every vector file matches §14.5.5, a stdlib-only Python reference of §9.1, §9.3, §9.4 reproduces every expected value, every example validates against the shapes of §14.3 by key set | none | M0 |
|
||||
| M2a | Server skeleton and `kvitt-domain` | Cargo workspace, three crates, config, DB pool, migration `0001_init.sql` byte-identical to `contracts/schema/server-v1.sql`, error type with every §14.3.2 code, auth extractor (§8.2), all routes of §7 registered with handlers returning 501 `not_implemented`, utoipa document equal to `contracts/openapi.yaml`, `GET /health` and `GET /setup/status` implemented, `kvitt-domain` passing all vectors plus `next_occurrence_date`, `server/Dockerfile`; shell files in M3, M4 and M9 paths (D-004); the seams of §6.3 (D-015) | Server gate (includes the OpenAPI match test, the migration identity test, the vector tests) | none | M1 |
|
||||
| M2b | Android skeleton and `:domain` | Gradle project in `android/` with every module of §6.2, version catalog, architecture check, `:core:model` types and repository interfaces of §14.2.4, `:domain` passing all vectors (including analytics), `:core:fakes` with the empty scenario (§6.2, D-018) for each repository, the Room 3 entities of §14.2.1 and §14.2.2 and `KvittDatabase` version 1 in `:data` with the exported schema (D-012), screenshot tests with Roborazzi (D-010), module shells for every module with one empty entry composable per feature (D-004), the feature convention of §6.2 in `build-logic` (D-017), `:app` launching to an empty themed screen, `:app` string files with the app name only in both languages, lint `MissingTranslation` as an error in every module | Android gate | Plug the phone in once to confirm `installDebug` works | M1 |
|
||||
@@ -1148,19 +1149,20 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
| M4 | Server sync, audit, push, scheduler | Landed `bd6f10a` (D-035 to D-039). E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) |
|
||||
| M5 | Android data layer | Landed `c28c52b` (D-026 to D-029, D-040 to D-043). DAOs on M2b's Room 3 entities and `KvittDatabase` v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b |
|
||||
| M12a | Category contract v2: contracts | Landed `6b60ef7`. `contracts/category-keywords.csv` (148 entries), `contracts/schema/migrations/0002_categories_v2.sql` (§14.1.1), `contracts/test-vectors/suggest_category.json` (65 cases), E-1 example `schema_version` 2, `tools/contracts-check.py` (keyword-table rules, 0002 loaded on a fresh and an in-use v1 database, Python reference of §9.11, new icon and colour tokens), and `:domain`'s `suggestCategory` with `UnicodeText` passing the vectors (§14.5.6). The OpenAPI `DefaultCategoryKey` extension and the new category examples were held back for M12b | `python3 tools/contracts-check.py` passes | none | M1, SPEC v0.8 |
|
||||
| M12b | Category contract v2: server | 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, 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 |
|
||||
| M12b | Category contract v2: server | Landed `2e2ebe7` (D-057, D-058). Migration `server/crates/kvitt-server/migrations/0002_categories_v2.sql` byte-identical to the contract file with its identity test; `SCHEMA_VERSION` 2; seed test of the fourteen defaults (ids, keys, icons, colours, sort orders, `server_version` from the sequence) on a fresh database and on a v1 database holding expenses (rows and references kept); icon and colour lists of §9.7 in `validate.rs`; `DefaultCategoryKey` with the fourteen keys in `contracts/openapi.yaml` (held back by M12a) and in the utoipa wire types, so the OpenAPI match test passes; the category examples M12a held back; importer table `splitwise_categories.csv` per §10.8 with its spec-parity test; importer schema check 2 (D-054); E-31 and other examples only where they enumerate the defaults | Server gate; `python3 tools/contracts-check.py` passes | none | M12a, M3, M4, M9 |
|
||||
| M12c | Category contract v2: Android | Landed `e499107` (D-055 to D-057). `:core:model`: `DefaultCategoryKey` (14), `ColorToken` (`SLATE`, `AMBER`), `CategoryIcons.ALL` (33), `ExpenseRepository.suggestCategory`; `:data`: the `UnicodeText` binding (`java.text.Normalizer`, `Character`), `suggestCategory` from Room, DataStore and the keyword table copied from `contracts/category-keywords.csv` at build time (D-055; a test asserts byte identity and the §14.8 rules), parsed by `:domain`'s `parseCategoryKeywords` (D-056); `:core:fakes`: fourteen defaults in the empty scenario and a fake `suggestCategory`; default-name string resources for the four new categories in English and Swedish (AC-7.2) where default names already live | Android gate | none | M12a, M2b, M5 |
|
||||
| M12d | UI contract additions (D-059) | `:core:model`: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable(id)` (§14.2.4 "Changes in v0.9"); `:data`: both computed from Room, with repository tests; `:core:fakes`: both computed from each scenario's own data; `ContractTest` updated | Android gate | none | M12c, M5 |
|
||||
| M6 | Design system | Started 2026-10-18 from `docs/ux/design-spec.md` §2 and §3 and `docs/ux/strings.csv`. `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts, 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, M12d |
|
||||
| M7b | Feature: home and account | `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6, M12d |
|
||||
| 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, M12d |
|
||||
| M7d | Feature: settle | `:feature:settle` screens S-9, S-10 | as M7a | none | M6, M12d |
|
||||
| M7e | Feature: analytics | `:feature:analytics` screens S-11, S-12 | as M7a | none | M6, M12d |
|
||||
| M7f | Feature: settings | `:feature:settings` screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 | as M7a | none | M6, M12d |
|
||||
| M8 | App integration | `:app` navigation and deep links, Hilt bindings from interfaces to `:data` and `:domain`, `:adapter:fcm`, notification building (AC-13.5), update banner wiring, revoke wipe, network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), `google-services.json` copied with `infra/dev/copy-google-services.sh` | Android gate; on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced | Tap through the pairing and the offline test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) |
|
||||
| M9 | Splitwise importer | Landed `ab298ab` (D-044 to D-046). `kvitt-import` per §10.8 with tests over synthetic CSV fixtures (`crates/kvitt-import/tests/import.rs`), reconciliation against the totals rows, the committed category table | Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 |
|
||||
| M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore with the `keytool` command of §10.6, outside the repository; install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi, M3, M4, M8 |
|
||||
| M11 | Import history | Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044, 0 expected after it). Real import run on the Pi per §10.8 "Production run" (D-025), `docs/validation/splitwise-import.md` with per-account totals compared to Splitwise's balances (aggregates only) | Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 |
|
||||
| M11 | Import history | Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044). Dry run of 2026-10-18 with the M12b mapping (D-053): Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. Real import run on the Pi per §10.8 "Production run" (D-025), `docs/validation/splitwise-import.md` with per-account totals compared to Splitwise's balances (aggregates only) | Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 |
|
||||
|
||||
### 11.3 Notes per milestone
|
||||
|
||||
@@ -1175,8 +1177,9 @@ 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.
|
||||
* **M12a, M12b, M12c** (D-049 to D-054) carry the owner's UX-session decisions into the contracts and the landed code: M3, M5 and M9 were built with ten defaults, so this is a contract change against `main`, not only the spec. M12a (landed `6b60ef7`) wrote `contracts/`, the checker and `:domain`'s `suggestCategory`; M12b (landed `2e2ebe7`) and M12c (landed `e499107`) ran in parallel (disjoint paths, §14.7); their outcomes are D-055 to D-058. The Room schema stays at version 1: category rows reach the phone through sync. The colour hex values of `slate` and `amber` are not needed by M12 (`:core:model` holds token names only); they arrive with U2 and M6.
|
||||
* **M12d** (D-059) adds the two UI-contract members UX-9 found (`ExpenseEditorOptions.frequentCategoryIds`, `CategoryRepository.deletable`) to `:core:model`, `:data` and `:core:fakes`, in parallel with M6 (disjoint paths). M7c and M7f consume them; until M12d lands, the design spec's fallbacks apply (`docs/ux/design-spec.md` §3.7 and §6).
|
||||
* **M11** runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled.
|
||||
|
||||
---
|
||||
|
||||
@@ -1287,7 +1290,7 @@ Suggested starting points the UX agent may use, replace or merge (it must delive
|
||||
2. **"Skärgård"**: Material 3 Expressive with a cool sea-and-granite palette, large rounded shapes, bold balance header, playful but quiet.
|
||||
3. **"Kontor"**: dense, utilitarian, data-forward. Neutral greys, strong typographic hierarchy, charts as the hero on Analytics, minimal decoration.
|
||||
|
||||
**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`. The owner picked direction 3 "Kalas" with the muted palette of `docs/ux/directions/3-kalas/`, typeface per D-020 (D-047):
|
||||
**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`. The owner picked direction 3 "Kalas" with the muted palette of `docs/ux/directions/3-kalas/`, typeface per D-020 (D-047). U2 landed `16c4d01`, approved by the owner. `docs/ux/design-spec.md` is the binding design document for `:core:design` (§2 tokens, §3 components) and for every screen (§4, §5); this spec wins over it on rules. `docs/ux/strings.csv` (558 rows, header included; keys with the D-017 prefixes; plurals as `key:one` / `key:other`) is the source of every string resource:
|
||||
|
||||
* `design-spec.md`: final tokens (colour roles light and dark, category colour hex values, typography scale, shapes, spacing scale in dp, elevation, motion durations and easing), component inventory (each with anatomy, states, sizes), and for every screen S-1 to S-22: layout description, all states of §13.3, interaction details, TalkBack labels.
|
||||
* `strings.csv`: every user-visible string of the app with columns `key,en,sv,notes`, keys in `snake_case` prefixed by the feature module that holds them (`home_balance_owes_you`, `expense_save`; shared strings for `:core:design` never start with a feature name; the app name is the only `:app` string; §6.2, D-017), placeholders as Android format arguments (`%1$s`), plurals marked in `notes` with their quantities. This becomes `values/strings.xml` and `values-sv/strings.xml` in M6 and M7; feature agents add no strings that are not in this file without a decisions entry.
|
||||
@@ -1770,7 +1773,7 @@ interface AccountRepository {
|
||||
|
||||
// Expenses (S-7, S-8)
|
||||
data class ExpenseDraft(val id: ExpenseId?, val accountId: AccountId?, val categoryId: CategoryId?, val amountOre: Long?, val description: String, val notes: String, val date: String, val payer: UserId, val mode: SplitMode, val ratioBp: Map<UserId, Int>?, val exactOre: Map<UserId, Long>?, val fullOn: UserId?)
|
||||
data class ExpenseEditorOptions(val me: User, val partner: User, val accounts: List<Account>, val categories: List<Category>, val defaultAccountId: AccountId?, val defaultCategoryFor: Map<AccountId, CategoryId>, val today: String)
|
||||
data class ExpenseEditorOptions(val me: User, val partner: User, val accounts: List<Account>, val categories: List<Category>, val defaultAccountId: AccountId?, val defaultCategoryFor: Map<AccountId, CategoryId>, val today: String, val frequentCategoryIds: Map<AccountId, List<CategoryId>>) // frequentCategoryIds: v0.9, D-059
|
||||
data class TimelineLine(val actor: User?, val action: AuditAction, val source: AuditSource, val clientAt: String, val changes: List<FieldChange>, val pendingLocal: Boolean)
|
||||
data class FieldChange(val field: String, val oldDisplay: String?, val newDisplay: String?) // ids already resolved to names; amounts and dates raw (öre as String, ISO date), formatted by the UI
|
||||
data class ExpenseDetailState(val id: ExpenseId, val account: Account, val category: Category, val amountOre: Long, val description: String, val notes: String, val date: String, val payer: User, val mode: SplitMode, val shares: List<Share>, val recurringTemplateName: String?, val deleted: Boolean, val pending: Boolean, val timeline: List<TimelineLine>)
|
||||
@@ -1822,6 +1825,7 @@ interface CategoryRepository {
|
||||
suspend fun reorder(idsInOrder: List<CategoryId>): OnlineResult<Unit>
|
||||
suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult<Unit>
|
||||
suspend fun delete(id: CategoryId): OnlineResult<Unit>
|
||||
fun deletable(id: CategoryId): Flow<Boolean> // v0.9, D-059: false for a default category or one used by a non-deleted expense or non-deleted recurring template (AC-7.6)
|
||||
}
|
||||
data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?)
|
||||
interface RecurringRepository {
|
||||
@@ -1880,7 +1884,14 @@ 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.
|
||||
* **`ExpenseRepository.suggestCategory`.** Computed in `:data` from Room (every non-deleted expense on the phone, pending ones included, across all accounts and both users; and the active categories, neither archived nor deleted), the per-phone last-used category of the account (DataStore, §14.2.2) and the keyword table copied from `contracts/category-keywords.csv` at build time (D-055) and parsed with `parseCategoryKeywords` (D-056), 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 while the phone holds no categories (before the first sync), and when `other` is archived and no active last-used category exists; the editor then shows no category until the user picks one (AC-8.6, v0.9). Stored `expense_date` values are valid ISO dates (§9.6), so parsing them for the history cannot fail. 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.
|
||||
|
||||
Changes in v0.9 (D-055 to D-057, D-059):
|
||||
|
||||
* **Keyword table on the phone (D-055).** `:data` and `:core:fakes` each copy `contracts/category-keywords.csv` at build time (tasks `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords`, registered as an AGP generated resource directory); a missing file fails the build. It is packaged as the Java resource `se/hagstedt/kvitt/data/category-keywords.csv` (and `se/hagstedt/kvitt/fakes/category-keywords.csv` for the fakes, debug only). There is no committed copy. A `:data` test asserts byte identity with the contract file and the §14.8 rules.
|
||||
* **Enum declaration order (D-057).** `DefaultCategoryKey` is declared in AC-7.2 sort order. The Rust wire enum, the OpenAPI `DefaultCategoryKey` enum and the 0002 `CHECK` list the ten v1 keys, then `housing`, `insurance`, `children`, `fees`. Wire values are unaffected (lowercase names; the OpenAPI equality test compares enums as sets).
|
||||
* **`ExpenseEditorOptions.frequentCategoryIds` (D-059).** Per active account: the active (non-archived, non-deleted) categories ordered by the number of non-deleted expenses in that account that use them, descending; ties by `sort_order` ascending; at most 5 entries; categories with no use in the account are left out (the list may be shorter or empty). Counted over every expense in the phone's Room copy (both users, imported and recurring included), so both phones show the same order after sync; it is not a per-phone history like AC-8.6's "last used on this phone". The flow re-emits when expenses or categories change; the editor reads it once when it opens and on an account change, so tiles do not move while the user looks at them. UI rule (design, not contract): the row shows the account's list, filled up to five from the remaining active categories in `sort_order`; the selected category takes the fifth place when it is not among them. Fallback until M12d lands: the selected category first, then the first active categories in `sort_order` up to five.
|
||||
* **`CategoryRepository.deletable(id)` (D-059).** Computed from Room: false for a default category (`defaultKey != null`) or one used by a non-deleted expense or non-deleted recurring template (AC-7.6). The server stays the authority: `delete` can still return `Failed("category_is_default")` or `Failed("category_in_use")` (for example when the partner's new expense has not synced yet), and the UI shows the same reason inline. The editor picks the text from `deletable` and `defaultKey`: default → `settings_category_delete_blocked_default_short`; in use and archived → `settings_category_delete_blocked_in_use_archived`; in use and active → `settings_category_delete_blocked_in_use`. Fallback until M12d lands: for a custom category "Ta bort kategori" is enabled and `Failed("category_in_use")` shows the in-use reason inline after the tap. Both members are implemented in `:data` and `:core:fakes` (every fake scenario computes them from its own data) by M12d.
|
||||
|
||||
Readings fixed in v0.5 (no type changes):
|
||||
|
||||
@@ -2101,7 +2112,7 @@ Computed with `kvitt-domain::propose_allocation` over the server's non-deleted r
|
||||
|
||||
#### 14.3.5 Examples as contract
|
||||
|
||||
M1 writes one JSON example per request and response body above, plus one per error code, in `contracts/examples/<E-id>-<name>/{request,response}.json` and `contracts/examples/errors/<code>.json`, and the sync examples of §14.4. Directory names are `E-<n>-<kebab-name>` (for example `E-1-health`, `E-32-sync-push`). The server's tests serialise its responses for the same fixtures and compare; the app's `:data` tests decode every example into its DTOs and re-encode it, comparing JSON values.
|
||||
M1 writes one JSON example per request and response body above, plus one per error code, in `contracts/examples/<E-id>-<name>/{request,response}.json` and `contracts/examples/errors/<code>.json`, and the sync examples of §14.4. Directory names are `E-<n>-<kebab-name>` (for example `E-1-health`, `E-32-sync-push`). The server's tests serialise its responses for the same fixtures and compare; the app's `:data` tests decode every example into its DTOs and re-encode it, comparing JSON values. The server's test harnesses seed the two users after the fourteen category seeds, so the users have `server_version` 15 and 16; `E-7-me/response.json` shows those values and its test compares them (D-058).
|
||||
|
||||
### 14.4 Sync protocol v1
|
||||
|
||||
@@ -2454,6 +2465,7 @@ interface UnicodeText {
|
||||
}
|
||||
|
||||
fun normalizeDescription(text: String, unicode: UnicodeText): String // CS-4
|
||||
fun parseCategoryKeywords(csv: String): List<CategoryKeyword> // the §14.8 table; D-056
|
||||
fun suggestCategory(description: String, history: List<SuggestionHistoryEntry>, keywords: List<CategoryKeyword>, activeCategories: List<ActiveCategory>, fallback: CategoryId?, unicode: UnicodeText): CategoryId?
|
||||
```
|
||||
|
||||
@@ -2463,6 +2475,7 @@ fun suggestCategory(description: String, history: List<SuggestionHistoryEntry>,
|
||||
* `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.
|
||||
* `parseCategoryKeywords` (D-056) reads the text of `contracts/category-keywords.csv`: header `keyword,match,default_key,source`, entries in file order (table order), `match` mapped to `KeywordMatch`; a malformed table (wrong header, wrong column count, unknown `match`) throws `IllegalArgumentException`. Pure; shared by `:data` and `:core:fakes`, which read the bundled resource (§14.2.4, D-055).
|
||||
* `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
|
||||
@@ -2582,8 +2595,9 @@ Ownership (an agent edits only paths its milestone owns; anything else needs the
|
||||
| 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 |
|
||||
| M12 (D-054): `contracts/openapi.yaml` (`DefaultCategoryKey`) and the category examples M12a held back; `kvitt-server` `migrations/0002_categories_v2.sql`, `src/db/mod.rs` (`SCHEMA_VERSION`), `src/api/validate.rs` (token lists), `src/wire/category.rs` and `src/wire/mod.rs` (key enum), their tests; `kvitt-import` `data/splitwise_categories.csv`, `src/categories.rs` and its tests | M12b (contract-change plan, landed `2e2ebe7`) | the usual owners |
|
||||
| M12 (D-054): `android/core/model/**` (enums, icons, `ExpenseRepository`), `android/data/**` (`UnicodeText` binding, `suggestCategory`, the build-time copy of the keyword table, D-055), `android/core/fakes/**` (defaults, fake `suggestCategory`) | M12c (contract-change plan, landed `e499107`) | the usual owners |
|
||||
| D-059: `android/core/model/**` (`ExpenseEditorOptions`, `CategoryRepository`), `android/data/**` (`frequentCategoryIds`, `deletable`), `android/core/fakes/**` (both, outside the `fakes/<feature>/` folders) | M12d (contract-change plan) | the usual owners; M7x keep their `fakes/<feature>/` folders |
|
||||
| `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.
|
||||
@@ -2594,7 +2608,7 @@ Strings: each feature module has its own `src/main/res/values/strings.xml` and `
|
||||
|
||||
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.
|
||||
`contracts/category-keywords.csv` (D-049) is the keyword table of §9.11 CS-6: UTF-8 without BOM, `\n` line ends, header `keyword,match,default_key,source`, no quoting. `keyword` is non-empty and already normalised (CS-4 applied to it gives it back), unique in the file; `match` is `token`, `prefix` or `phrase`; a `token` or `prefix` keyword has no space and a `phrase` keyword has at least one; `default_key` is a key of AC-7.2 other than `other`; `source` is `data` (learned from the household's export) or `curated`, documentation only. Rows are sorted by `default_key`, then `keyword`, in code-point order. The phone bundles it through a build-time copy (D-055: `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords`, no committed copy, a missing file fails the build, a `:data` test asserts byte identity and these rules, §14.2.4); M12a seeds the file from `docs/ux/category-keywords.csv` (148 entries). Changing an entry is a contract change like any other.
|
||||
|
||||
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.
|
||||
|
||||
@@ -2627,3 +2641,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
|
||||
| 0.6 | 2026-10-18 | Decisions from the M4 (`bd6f10a`) implementation report. D-035: a mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable fails the whole request with 400 `invalid_request`; every other shape error is a per-mutation `validation_failed` (§14.4.4 step 3). D-036: `POST /sync` with 0 mutations is 422 `batch_too_large`, the code now reads "outside 1 to 100" (§9.6, §14.3.2, §14.4.4). D-037: a delete of a never-synced row runs the full step-8 validation; every rejection carries the stored row or null as `current`, also at steps 3 and 4; step 11 keeps the stored `created_at`; pushed ids must be canonical lowercase hyphenated UUIDs (§14.4.3, §14.4.4). D-038: FCM retries are one send plus up to three retries at 1 s, 5 s, 25 s; 401 drops the cached access token and is not retried, other 4xx are not retried; retries block the single sender task up to 31 s per device (§14.6). D-039: pruning deletes `received_at` strictly older than 90 days; seam change to D-015: `tasks::start` returns the `AppState` with the real push queue and `serve` builds the router from it, `AppState::new` defaults to `SqlAuditWriter`, `NoopAuditWriter` removed, `NoopPushQueue` kept for push disabled (§6.3, §14.1, §14.4.9; PLAN-M4). Clarifications without a decision id: §14.4.4 step 8 order is field rules, domain rules, references; a negative `since` is `invalid_request` and a pull reads one page in a single read transaction (§9.6, §14.3.4, §14.4.2); push runs in one `BEGIN IMMEDIATE` with a savepoint per mutation; the push queue is bounded at 100 and drops the oldest with a warning; the FCM JWT is RS256 with `kid` = `private_key_id`, the access token cached until 5 minutes before expiry, messages data-only with string values; `UNREGISTERED` or 404 clears the device token only if unchanged; an unusable credentials file disables push like a missing one (§6.3, §14.6); the scheduler reads the injected clock at least every 30 s, runs a missed job once after a forward jump and re-plans after a backward jump of more than a day (§9.5); `rotate-epoch` prints the new epoch, exit 0 or 1 on a database error (§10.4); `serve` uses `into_make_service_with_connect_info` (D-032 fulfilled, §8.6, §11.3); `GET /i/` with an empty code is the HTML 404 (E-4); reqwest (rustls, aws-lc-rs, platform verifier) and jsonwebtoken added, so the arm64 image needs a C toolchain stage and the distroless image CA roots, to be confirmed by M2c-pi or M10; the dev image was rebuilt with them on the Mac on 2026-10-18 (§6.3, §10.3); M4's test binaries and scheduler path (§11.3, §14.7). |
|
||||
| 0.7 | 2026-10-18 | Decisions from the M9 (`ab298ab`) implementation report and the importer rehearsal on the real exports. D-044: nine Swedish Splitwise category names added to the §10.8 table and to `splitwise_categories.csv` with the key of their English equivalent (Städning and Verktyg - Övrigt `utilities`, Tjänster `home`, Cykel `transport`, Flyg `travel`, Spel `entertainment`, Kläder `shopping`, Livet - Övrigt and Utbildning `other`). D-045: reconciliation: the run commits only if each user's balance of the whole account equals the sum of the file's totals rows (one per currency, converted with `--fx`); a mismatch or a missing totals row rolls back and exits 4, a dry run reports the same, an account already holding other rows rolls back, a mismatch after any `--fx` conversion is reported but accepted (§10.8, AC-17.5). D-046: CLI semantics: `--user` takes a user id or an exact unique display name; no database is created or migrated, `meta.schema_version` must be 1; a dry run runs the full transaction and rolls back; exit codes 0 ok, 1 database or file error, 2 usage, 3 cannot import, 4 reconciliation mismatch; the import key uses an existing account's stored name; an empty description or one over 1000 code points makes the row invalid; descriptions over 100 code points are cut with the full text in `notes`; settlement notes cut to 200; both nets zero is mode `full` on the owner; idempotency through the existing `import_key` columns (§10.8, AC-17.5). M9 marked landed, M11 rehearsal recorded (§11.2, §11.3). |
|
||||
| 0.8 | 2026-10-18 | Decisions from the owner's UX session (`docs/ux/proposed-decisions.md` UX-1 to UX-8 on branch `UX-sprint`). D-047: direction 3 "Kalas" with the muted palette, typeface per D-020 (§9.7, §13.4). D-048: navigation stays at three destinations, an "Aktivitet" tab not taken for v1; §13.2 binding. D-049: category suggestion from the description, new §9.11 (CS-1 to CS-7: new expenses only, manual choice wins, history most often then first token, keyword table, AC-8.6 default, no marker), AC-8.6, `ExpenseRepository.suggestCategory` (§14.2.4), Kotlin-only `suggestCategory` in `:domain` with the `UnicodeText` interface bound by `:data` and `suggest_category.json` as landed by M12a (`6b60ef7`) (§14.5.5, §14.5.6), contract file `contracts/category-keywords.csv` (§14.8). D-050: S-1 display-name field on a first pairing (AC-2.4, §13.3). D-051: §9.7 hex values become the muted Kalas values from U2, token names fixed, `slate` and `amber` `TODO(U2)`. D-052: "Utgiften sparades" without undo (S-7), archived account hides the FAB (S-5), per-account settle-up keeps the swap and an editable amount (S-9), avatar tokens `avatar-1` and `avatar-2` (AC-4.5), Swedish effect copy (AC-8.10). D-053: finer Splitwise mapping (§10.8). D-054: fourteen default categories (`housing`, `insurance`, `children`, `fees` added; AC-7.2), icons `key`, `shield`, `account_balance` (33), colours `slate`, `amber` (14), server schema 2 through migration 0002 rebuilding `categories` (§14.1.1, locked as `contracts/schema/migrations/0002_categories_v2.sql`, copied from M12a's file; no `server-v2.sql`, §14.8), importer schema check 2 (§10.8), enums in §14.2.4 and §14.3.3, E-1 `schema_version` 2; Room schema unchanged. New milestone group M12 "Category contract v2" (M12a contracts, then M12b server and M12c Android in parallel; M6 and M7c wait for M12c) (§11). |
|
||||
| 0.9 | 2026-10-18 | Outcomes of M12b (`2e2ebe7`) and M12c (`e499107`), U2 (landed `16c4d01`) and UX-9. D-055: the keyword table is copied from `contracts/category-keywords.csv` at build time by `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords` (AGP generated resource directory), a missing file fails the build, packaged as `se/hagstedt/kvitt/data/category-keywords.csv` (and the fakes path in debug), no committed copy, a `:data` test asserts byte identity and the §14.8 rules; supersedes the `android/data/src/main/resources` copy (§11.2 M12c, §14.2.4, §14.7, §14.8). D-056: pure `parseCategoryKeywords(csv)` in `:domain`, shared by `:data` and `:core:fakes` (§14.5.6). D-057: `DefaultCategoryKey` declared in AC-7.2 order; the Rust wire enum, the OpenAPI enum and the 0002 `CHECK` list the ten v1 keys then the four new ones; wire values unaffected (§14.2.4). D-058: test harnesses seed users after the fourteen category seeds (`server_version` 15 and 16); `E-7-me/response.json` shows 15 and 16 and its test compares them again (§14.3.5). D-051 fulfilled: §9.7 hex values from `docs/ux/design-spec.md` §2.3; `design-spec.md` is the binding design document and `strings.csv` (558 rows, D-017 prefixes, plurals as `key:one`/`key:other`) the source of string resources (§13.4). D-059: UX-9 accepted as a contract change: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable(id)` with their readings and fallbacks (§14.2.4), new milestone M12d (§11, §14.7); M7a to M7f depend on M6 and M12d. Clarifications without a decision id: `suggestCategory` history is every non-deleted expense (pending included, all accounts, both users), active categories neither archived nor deleted, fallback last-used in the account if active else `other`, null account gives `other`; when `other` is archived and no active last-used exists the result is null and the editor shows no category until the user picks one (AC-8.6, §9.11 CS-5, §14.2.4); stored `expense_date` values are valid ISO dates (§9.6), so parsing cannot fail. §11.2: M12a, M12b, M12c, U1, U2 landed, M6 started 2026-10-18; dry run of 2026-10-18 (Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled) (§11.2, §11.3). §10: development stack migrated to schema 2 on 2026-10-18 after a backup. PLAN-M12b: bundled SQLite is 3.51.x (3.51.3), not 3.50. |
|
||||
|
||||
Reference in New Issue
Block a user