SPEC v0.18: D-109 to D-117; validation reports; HANDOFF, prompts
Build Kvitt images / build (push) Failing after 1m24s
Build Kvitt images / build (push) Failing after 1m24s
- D-109 S-16 lost version read-only (Rejection.lost: RejectedVersion), D-110 S-22 versions from /health
(AboutState.schemaVersion), D-111 SettlementRepository nullable start/recompute, D-112 E-17 retry,
D-113 invite-partner and the push sent line, D-114 picture 16 MP / 64 MB and fcm_token 512 limits,
D-115 rate-limit token acceptance (revises D-032), D-116 private notifications (AC-13.9) and L-6 as
built (OQ-6), D-117 infra security (Caddy log redaction and headers, non-root Caddy, dev secret,
digest record, key form).
- §10.5.2 quotes infra/pi/Caddyfile at 7fb86f5; §10.4 invite-partner row; §11.2 M12f row; §11.3 server
gap pass, server overhaul, infra notes; new §11.4 validation reports; §14.7 server tree; strings.csv
562 rows; localeFilters, work-testing, harness fix, APK 10.0 MB.
- docs/validation/ac-coverage.md and security-review.md committed verbatim from the reports.
- Commit hashes in SPEC, decisions, HANDOFF and prompts/README remapped to the rewritten history (18c527f).
- design-spec: S-16 lost-version dialog, S-22 "(schema <n>)", the S-1 rationale mention dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+50
-37
@@ -3,62 +3,75 @@
|
||||
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
|
||||
|
||||
## 1. Where things stand
|
||||
- `main` at `4755fe0` (chips cleanup) plus the SPEC v0.17 docs commit from `agent-docs-v017` once it lands. History: `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `fad6fc9` M2b, `fdf2093` M2a, `040bf41` SPEC v0.4, `503cee5` M3, `c28c52b` M5, `982451d` SPEC v0.5, `bd6f10a` M4, `23a2f1e` SPEC v0.6, `ab298ab` M9, `e8ee518` SPEC v0.7, `6b60ef7` M12a, `7fb9f6f` SPEC v0.8, `e499107` M12c, `2e2ebe7` M12b, `d452fa6` UX Kalas categories and tokens, `16c4d01` UX sprint U2, `45eecd1` SPEC v0.9, `bdba9d3` M12d, `3080804` M6, `7bd0aa1` SPEC v0.10, `72f21b2` app debug fakes bindings (temporary, deleted by M8), `e6ad858` app release wiring of `:data` (pre-M8), `b9b061a` M7a, `6ac711d` M7e, `082b8dc` M7d, `31760f9` M7b, `1e42f76` M7c, `a54eb7c` M7f, `3a1bd14` M6b, `d504004` SPEC v0.11, `34a2590` M7g, `369e516` and `00a14a3` M8, `cf8ef45` SPEC v0.12, `1470a2f` M12e contract pass, `90505a8` M12e launcher icon, `03b7184` SPEC v0.13, `2b4e21c` Vico catalog entries removed (D-063), `c694f78` server FCM target in `fid` (D-096), `1e404cc` Pi image arm64 check (`docs/spikes/pi-image-arm64.md`), `fd60170` SPEC v0.14, `f0575d3` M8b UI fixes from the first phone test, `09f474f` `FcmMessageBody.fid` in `:data` (D-096 follow-up), `e7d963a` SPEC v0.15, `b481fab` M8c goldens at 1x in three variants (D-098), `64f5706` M8c S-22 licences row removed (D-099), `7c453d8` every PNG through oxipng, `57f984f` R8 release build, `3c68594` M8d editor rows (D-102), `3a3720b` SPEC v0.16, `5e027df` M2c-pi Pi bundle (D-103 to D-106), `15397e4` M8e S-19 option rows (D-107), `965bb5d` server Dockerfile `CARGO_BUILD_JOBS` argument, `ed1b130` M8f instant navigation (D-108), `4755fe0` unused editor chips removed.
|
||||
- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2a (`fdf2093`), M2b (`fad6fc9`), M2c-dev (`c885aba`), M3 (`503cee5`), M4 (`bd6f10a`), M5 (`c28c52b`), M9 (`ab298ab`), M12a (`6b60ef7`), M12b (`2e2ebe7`), M12c (`e499107`), U1 and U2 (`16c4d01`, owner approved), M12d (`bdba9d3`), M6 (`3080804`), M7a to M7f, M6b (`3a1bd14`), SPEC v0.11 (`d504004`, D-069 to D-080), M7g feature cleanups (`34a2590`), M8 app integration (`369e516` + `00a14a3`, landed 2026-10-18, gate green: BUILD SUCCESSFUL, 710 actionable tasks), SPEC v0.12 (`cf8ef45`, D-081 to D-085), M12e contract pass (`1470a2f` + `90505a8`, landed 2026-10-18; gates green: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; cargo 253 tests, OpenAPI equality, vectors and migration byte-identity green), SPEC v0.13 (`03b7184`, D-086 to D-095), Vico removal (`2b4e21c`), FCM `fid` target (`c694f78`, D-096), Pi image arm64 check (`1e404cc`: C stage, CA roots and an OAuth plus FCM call from the arm64 image confirmed on the Mac; running on the Pi stays M2c-pi's), SPEC v0.14 (`fd60170`, D-096), M8 on-phone test (2026-10-18, passed), M8b (`f0575d3`, D-097), `FcmMessageBody.fid` (`09f474f`), SPEC v0.15 (`e7d963a`, D-097, D-100, D-101); landed today after v0.15: M8c (`b481fab`, `64f5706`, `7c453d8`; D-098, D-099), the R8 release build (`57f984f`), M8d (`3c68594`, D-102); SPEC v0.16 (`3a3720b`); then the M2c-pi bundle (`5e027df`, D-103 to D-106), M8e (`15397e4`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`965bb5d`), M8f (`ed1b130`, D-108) and the chips cleanup (`4755fe0`).
|
||||
- M2c-pi bundle outcome (SPEC v0.17): `infra/pi/` with `compose.yaml`, `.env.example`, `Caddyfile`, `install.sh`, `deploy.sh <tag> [--skip-backup]` (backup before every deploy, `/opt/kvitt/previous-tag`), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` (D-103); images `gitea.lan/jacobh2/kvitt-server|kvitt-cert-sync|kvitt-backup`, Gitea workflow `.gitea/workflows/build-images.yaml` (runner `host`, secret `kvitt_cicd`, tags `v1.0.N` + `latest`, `CARGO_BUILD_JOBS=1`), `infra/build-images.sh` as Mac fallback, supercronic instead of crond (D-104); `backup` uid 10001, `rclone.conf` owned 10001 (D-105); cert-sync tar stream for both key variants, backup confirm-then-delete (D-106). Gate at landing: shellcheck clean on 18 scripts, compose config valid, cert-sync arm64 image builds; rehearsed on the Mac with Podman (cert-sync install, renew, skip, refuse; Caddy reload; `Authorization` redacted; TLS 1.1 refused; backup upload, `lsf` confirmation, delete after confirm, failed-upload retry; healthcheck red at 26 h; APK prune keeps 5). Not verifiable from the Mac: the add-on's `authorized_keys` options, the Gitea workflow run, pulls from `gitea.lan`, the router forward, Drive, real Docker compose.
|
||||
- M8e, M8f and cleanup outcome (SPEC v0.17): S-19's account, payer and split are three fixed `EditorOptionRows` (D-107); navigation is instant, no `NavHost` transitions (D-108); `AccountChip`, `PayerChip`, `SplitChip` removed (`4755fe0`), S-9 keeps `DateChip` in `EditorChipRow`.
|
||||
- M8 outcome (recorded in SPEC v0.12 as D-081 to D-085): both variants bind `:data` through `di/AppDataModule.kt`, debug fakes module gone (D-081, revises D-041); debug network security config allows cleartext everywhere, the AC-2.8 limit is enforced in `SessionRepositoryImpl.normaliseServerUrl`, pinned-root config behind `kvitt.tls.pinnedRoot` (D-082); FCM registration by Firebase Installation ID, firebase-messaging 26.0.0, no server change (D-083); invite links ignored while paired (D-084); generic-notification, deletion and `sync_batch` taps open Home, push in an expedited WorkManager job (D-085).
|
||||
- M8 end-to-end test (2026-10-18, dev server, owner's S26): pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device` (the CLI has no partner-invite subcommand: small ops request for a later server plan); Home showed the account; the offline expense showed the pending marker and synced after reconnect (pending 0, the server has the row); the owner's device registered `fcm_token` and `app_version_name`; a push by `fid` was delivered and the notification tap worked. Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked, no complaint).
|
||||
- M8b outcome (SPEC v0.15): invite-code field holds the raw code, hyphens as a `VisualTransformation` (cursor bug fixed, unit test); quick-row selected badge drawn above the ripple clip; `KvittTopAppBar` pads for the status-bar inset (`windowInsets` parameter), `KvittScaffold` follows; chart bars top 4, bottom 2 dp (D-097); 221 goldens re-recorded. `FcmMessageBody.fid` in `:data` followed (`09f474f`).
|
||||
- M8c, R8 and M8d outcome (SPEC v0.16): goldens `<name>_sv_light`, `_sv_dark`, `_en_light` plus `_sv_light_200`, at 1x (`w411dp-h<height>dp-mdpi`), oxipng-compressed: 1143 PNGs / 131 MB to 865 / 26 MB, mock-ups 36.8 to 26.0 MB (D-098, revises D-066); S-22 has no licences row, `settings_about_licences` gone (D-099, OQ-5 closed); release APK R8-shrunk, about 11 MB (was 43 MB), `mapping.txt` to be kept beside the APK by the publish script (SPEC §10.6); release signing verified (alias `kvitt`, RSA 4096, v2); S-7 options as four fixed rows `EditorOptionRows`, connected buttons 8 dp side padding (D-102).
|
||||
- Owner answers 2026-10-18: "boende" keeps suggesting `travel` (no change); the 128 px picture size is not needed; OQ-5 "no, hide the button" (implemented by M8c); the account page split bar stays as designed; M2c-pi becomes an owner-run bundle (D-100); the keystore lives at the repository root (D-101); the Gitea push waits for fewer goldens and a history rewrite, no LFS.
|
||||
- M12e outcome (recorded in SPEC v0.13 as D-086 to D-095): E-3 `invite_kind` and `Pairing`, S-2 only after a `partner` pairing (D-086); admin name in E-12 links via `?from=`, passed on by E-4, S-1 help line names the admin (D-087); `SyncSummary.lastAttemptFailed` (D-088); `Rejection.actor` (D-089); `X-Kvitt-App-Version-Name`, migration 0003, server schema 3, S-20 "Kvitt 1.1" (D-090); `NewRecurringTemplate` (D-091); `UpdateCheck` (D-092, revises D-073); `SettleState.untouched` (D-093, revises D-074); D-027 re-stamp in `:data` (D-094); `PushEvent` in `:core:model`, six screen-hosted snackbar hosts, snackbar resource ids, adaptive vector launcher icon (D-095, revises D-071). 128 px picture size: the owner confirmed it is not needed (single 512 size, SPEC §14.2.4).
|
||||
- Compatibility: the M12e APK cannot pair or list devices against a pre-M12e server (DTOs without defaults, by design), so the server is rebuilt and redeployed before the new APK is installed (SPEC §14.8). Old phones on the new server work.
|
||||
- In flight: `agent-docs-v017` (this docs change: SPEC v0.17, D-103 to D-108), an AC-coverage audit (its report to be committed later as `docs/validation/ac-coverage.md`). Then a one-off history rewrite (drop the superseded PNG blobs; no clone exists) and the first push to `origin` (owner authorised, no confirmation needed), which also triggers the Gitea Actions workflow.
|
||||
- Remote: `origin` on Gitea exists and the SSH key works; the golden reduction (M8c) has landed; the history rewrite and the first push follow this docs pass (owner authorised 2026-10-18, no further confirmation). The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone-drive-token.json`, turned into `infra/secrets/rclone.conf` with `[gdrive]` `type`, `scope`, `token`; the folder id stays `RCLONE_FOLDER_ID` in `.env`; both git-ignored); the cert-sync key `infra/secrets/ha_ssh_key` exists.
|
||||
- `main` at `b985ce9` (the server overhaul, landed after M12f's `a0259ab`) plus the SPEC v0.18 docs commit from `agent-docs-v018` once it lands. `origin/main` (Gitea) at `18c527f`; everything after it is local until the next push.
|
||||
- History rewrite and first push, 2026-10-18 (owner authorised): superseded PNG versions were dropped from the whole history (no LFS), the current goldens and UX mock-ups restored in `18c527f`; pack 45 MiB; `origin/main` pushed at `18c527f`. A bundle of the pre-rewrite history (`kvitt-pre-rewrite.bundle`) is kept outside the repository, in the orchestrator's scratchpad. Every commit from M2b on got a new hash; the oxipng pass (`7c453d8` before the rewrite) no longer exists as a commit (it only touched PNGs). SPEC, `docs/decisions.md`, this file and `prompts/README.md` cite the rewritten hashes since v0.18 (`7c453d8` stays as the oxipng pass's name; `docs/validation/ac-coverage.md` keeps its pre-rewrite hashes verbatim); plans, spike reports and READMEs may still cite pre-rewrite hashes: find a commit by its subject with `git log --oneline --grep`.
|
||||
- History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f.
|
||||
- Landed since SPEC v0.17 (recorded in v0.18):
|
||||
- Infra overhaul (`2fac1ec`, `61f92c8`, `fc6126c`, `b647faf`, `e70fe0d`; no decision): `tools/gradle-lock.sh` shared by `gradle-serial.sh`, `clean-builds.sh` and `infra/build-images.sh`; the pre-commit hook runs through `gradle-serial.sh` with JDK 25 on `PATH` (hooks reinstalled in `main` after `e70fe0d`); `install.sh` keeps the deployed `KVITT_TAG`; `--provenance=false` on buildx pushes, the workflow token through env; `RCLONE_REMOTE` dropped from the dev env.
|
||||
- Server gap pass (`8660b7f`, D-113): `kvitt-server invite-partner` (0 ok, 2 household full, 3 no admin), so a scripted partner needs no second admin device; `push sent` info line; CLI tests (`invite-partner`, `migrate`, `healthcheck`, SIGTERM drain); `tools/contracts-check.py` 134 checks.
|
||||
- Infra security (`7fb86f5`, D-117): Caddy log redaction of `/i/<code>` and query strings, CSP and three more response headers, Caddy non-root (uid 10002, read-only, caps dropped), `/certs` 0750, dev stack mounts only the FCM key, `/opt/kvitt/deployed-digests` with a WARNING on change, `kvitt_cicd` with `write:package` only, the plain root key (old variant B) not acceptable.
|
||||
- M12f (`dc7a296`, `5b2ed37`, `2bd6b58`, `c366fbd`, `a0259ab`; D-109 to D-112, D-116): S-16 lost version read-only, S-22 "<version> (schema <n>)" from `/health`, `SettlementRepository` returns null instead of throwing, E-17 retry after each successful sync, invite links dropped while paired, private notifications on the lock screen, `localeFilters` en/sv, `:data` tests (429, D-040, WorkManager via `work-testing`), the home/account screenshot harness fix (stale `sv_dark` goldens re-recorded), 19 unused string keys removed (`strings.csv` 562 rows). Gate at landing: 941 tests. Release APK 10.0 MB.
|
||||
- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4); M-1 and L-3 wait for the owner (§4). H-1 must be fixed before port 8443 is opened.
|
||||
- Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18.
|
||||
- In flight: the Android overhaul agent only (and this docs pass); then a push to `origin`.
|
||||
- Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2).
|
||||
- Compatibility: an APK from M12e on cannot pair or list devices against a pre-M12e server, so the server is rebuilt and redeployed before a new APK is installed (SPEC §14.8). The dev server already runs schema 3.
|
||||
- Remote: `origin` on Gitea, SSH key works. The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone.conf`, `[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`); the cert-sync key `infra/secrets/ha_ssh_key` exists (all git-ignored). The first push triggered the Gitea Actions workflow; its result is the owner's §3 item 1.
|
||||
- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test` (from `server/`). Both green on `main` as far as reported.
|
||||
- The UX sprint branch `UX-sprint` and its worktree `~/orca/workspaces/kvitt/UX-sprint` were removed on 2026-10-18 (fully merged).
|
||||
- Importer: the dry run of 2026-10-18 with M12b's mapping reconciled on both real files (Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped). The CSV files stay in `export/` (git-ignored).
|
||||
- Development stack (M2c-dev): running on the Mac, reachable at `http://192.168.1.210:8080`; rebuilt on 2026-10-18 with the M12e server and the `fid` target (a push by `fid` arrived in the M8 test), database at schema 3, data kept. Backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` (before schema 2) and `infra/dev/backups/kvitt-20261018T130443Z.sqlite.gz` (before schema 3) exist (git-ignored). On this Podman setup `infra/dev/up.sh` alone does not swap in a rebuilt image: run `infra/dev/up.sh --down`, then `infra/dev/up.sh`. The owner's phone paired with the bootstrap code on 2026-10-18; a scripted partner exists, joined through a second admin device paired with `kvitt-server pair-device` (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`; `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` in the main checkout (git-ignored).
|
||||
- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server for the M8 test on 2026-10-18; the next install carries M8b to M8f.
|
||||
- Importer: the dry run of 2026-10-18 reconciled on both real files (Vardag 1746 rows, Hemma 37 rows, 0 unmapped). The CSV files stay in `export/` (git-ignored).
|
||||
- Development stack (M2c-dev): running on the Mac at `http://192.168.1.210:8080`, database at schema 3, data kept; backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` and `kvitt-20261018T130443Z.sqlite.gz` (git-ignored). On this Podman setup run `infra/dev/up.sh --down`, then `infra/dev/up.sh`, to swap in a rebuilt image. Since `7fb86f5` `up.sh` copies only `fcm-service-account.json` into `infra/dev/secrets/` (git-ignored). The owner's phone is paired with it; a scripted partner exists (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`.
|
||||
- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the next install carries M8b to M8f and M12f.
|
||||
- Disk: the MacBook had about 400 MB free during this docs pass (2026-10-18); run `tools/clean-builds.sh` before the next Gradle build.
|
||||
|
||||
## 2. Open work (one line each, with who owns it: agent or owner)
|
||||
- Land `agent-docs-v017` (orchestrator): SPEC v0.17, D-103 to D-108, HANDOFF, `prompts/README.md`, design-spec §2.11, §3.7 and S-19.
|
||||
- AC-coverage audit (agent, running): report to `docs/validation/ac-coverage.md`, committed later.
|
||||
- History rewrite (drop superseded PNG blobs; no clone exists), then the first push to Gitea `origin` (orchestrator; owner authorised, no confirmation needed); the push triggers the Gitea Actions workflow (§3 item 1).
|
||||
- Land `agent-docs-v018` (orchestrator): SPEC v0.18, D-109 to D-117, `docs/validation/ac-coverage.md`, `docs/validation/security-review.md`, HANDOFF, `prompts/README.md`, design-spec S-16, S-22 and the S-1 permission line.
|
||||
- L-10 (a member gets `forbidden` rather than `not_found` for another user's device, so the id's owner is visible): left as the spec says; owner decision pending (§4).
|
||||
- Tooling request (no plan yet): `cargo-audit` in the server gate.
|
||||
- Android overhaul (agent, running).
|
||||
- `infra/README.md` lines (infra agent, no plan yet): drop "(milestone M2a)" on the `build.sh` server line; a `buildx rm` note for the builder config; the operations table still says "APKs about 20 MB each" (10 MB since R8, SPEC §10.10).
|
||||
- Audit items still open: the AC-18.2 restore drill (`docs/validation/restore-drill.md`, M10); AC-18.1, AC-20.1 and AC-16.3 shown on the Pi and phones (M2c-pi, M10); a `KvittWorkerFactory` test (Android agent); an S-1 design state if the owner wants a confirm step for an unusual server (OQ-6, default not in v1).
|
||||
- Unverified risk: the workflow pushes a git tag; whether that re-triggers the path-filtered workflow is not known (watch the first runs).
|
||||
- Push the commits after `18c527f` to `origin` (orchestrator, when the owner asks; pushes need an explicit ask).
|
||||
- Install the next debug build and have the owner look (§3 item 2); build a signed release for the smoke taps (§3 item 3).
|
||||
- Owner runs the M2c-pi bundle on the Kvitt Pi (§3 item 4); record the add-on key variant (A/B) in `infra/README.md` and the router-forward answer.
|
||||
- Cleanup (agents of the owning modules, no plan yet): 20 unused strings R8 found (`editor_next`, `home_notification_rationale`, `settle_empty_settled`, `home_sync_status_tag_*`, …) could be deleted by their modules; optional `localeFilters` en+sv.
|
||||
- Ops request (no plan yet): a `kvitt-server` subcommand for a partner invite, so a scripted partner needs no second admin device.
|
||||
- Plans (SPEC §11.2 v0.17), in dependency order; prompt `prompts/<id>.md`:
|
||||
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12e, U1, U2, R8 release build, M2c-pi bundle: landed.
|
||||
- Next: the owner runs the M2c-pi bundle on the Kvitt Pi (D-100); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045).
|
||||
- Owner runs the M2c-pi bundle on the Kvitt Pi (§3 item 5); record the accepted cert-sync key form and the router-forward answer in `infra/README.md`.
|
||||
- Plans (SPEC §11.2 v0.18), in dependency order; prompt `prompts/<id>.md`:
|
||||
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12f, U1, U2, R8 release build, M2c-pi bundle, server gap pass, infra overhaul, infra security and the server overhaul: landed.
|
||||
- Next: the Android overhaul; a server image with the H-1 fix before the router forward; the owner runs the M2c-pi bundle on the Kvitt Pi (D-100); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045).
|
||||
|
||||
## 3. HUMAN steps pending (relay in chat)
|
||||
1. After the first push: tell me whether the Gitea Actions run ("Build Kvitt images") succeeded and how long it took.
|
||||
2. Next install on the phone (yes/no each): screen changes are instant, with no fade or slide; plus the earlier checks: the title bar sits below the status bar; the quick-row selected badge is whole; the invite-code field cursor stays after the last character; the expense editor shows Konto, Datum, Betalt av, Delning as four rows that do not move when a value changes; the recurring editor shows Konto, Betalt av, Delning as three such rows; "Household" fits its half of the Analytics filter bar.
|
||||
3. Release build smoke taps: the app opens; S-1 or Home renders; Settings, About shows the version; add one expense and watch it sync.
|
||||
4. Pi, after the app is proven: the nine steps of `infra/README.md` "Pi: what the owner does":
|
||||
1. Gitea, the repository, Actions: did the run "Build Kvitt images" triggered by the first push succeed (yes/no), and how long did it take?
|
||||
2. Next install on the phone (yes/no each): screen changes are instant, with no fade or slide; Settings, Om Kvitt shows the server version as "… (schema 3)"; if Inställningar, Synkstatus lists a change under "Ändringar som inte synkades" (likely none), "Öppna" shows "Din version" with "Visa nuvarande version"; plus the earlier checks: the title bar sits below the status bar; the quick-row selected badge is whole; the invite-code field cursor stays after the last character; the expense editor shows Konto, Datum, Betalt av, Delning as four rows that do not move when a value changes; the recurring editor shows Konto, Betalt av, Delning as three such rows; "Household" fits its half of the Analytics filter bar.
|
||||
3. Release build smoke taps: the app opens; S-1 or Home renders; Settings, Om Kvitt shows the version; add one expense and watch it sync.
|
||||
4. Owner decisions from the security review (answer each):
|
||||
1. M-1: encrypt the Drive backups through an rclone `crypt` remote and re-authorise rclone with the narrower `drive.file` scope (a restore then needs the crypt passwords)? yes/no.
|
||||
2. M-2: which cert-sync key form did the Home Assistant add-on accept at Pi step 3: the restricted root key (variant A) or a dedicated non-root user? (A plain root key is not acceptable.)
|
||||
3. L-3: a separate per-IP limit for APK downloads (`/apk/files/*`): which number per minute (assumed 10 per minute per IP)?
|
||||
4. Gitea token: if the workflow's tag push fails with `kvitt_cicd` limited to `write:package`, may `write:repository` be added? yes/no.
|
||||
5. Confirm the numbers the server overhaul assumed and built: pictures capped at 16 megapixels; `fcm_token` at most 512 characters. yes/no.
|
||||
5. Pi, after the app is proven: the nine steps of `infra/README.md` "Pi: what the owner does":
|
||||
1. Provision: Raspberry Pi Imager, Raspberry Pi OS Lite (64-bit), user, SSH key, time zone; static DHCP lease in the router.
|
||||
2. Docker Engine from Docker's Debian arm64 repository; `docker run --rm hello-world` prints "Hello from Docker!".
|
||||
3. Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, `authorized_keys`: add the cert-sync key as variant A (restricted `tar` command); if the add-on refuses it, variant B (plain key). Question: which variant was accepted, A or B?
|
||||
3. Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, `authorized_keys`: add the restricted cert-sync key line (variant A); if the add-on refuses it, a dedicated non-root user that can only read the certificate directory; never a plain root key. Question: which form was accepted?
|
||||
4. On the Mac: `infra/pi/.env` from `.env.example`, every `CHANGE_ME` replaced; secrets staged into `infra/pi/secrets/`.
|
||||
5. `scp` the bundle to the Pi, remove `infra/pi/secrets` on the Mac; on the Pi `ssh-keyscan` the Home Assistant host into `secrets/ha_known_hosts` (at least 1 line).
|
||||
6. `./install.sh` on the Pi; expected end "Kvitt is running."; then remove `~/kvitt-pi/secrets`.
|
||||
7. Router, WAN, Virtual Server / Port Forwarding: TCP 8443 to the Kvitt Pi's 8443; on the phone with Wi-Fi off, `https://icehack.asuscomm.com:8443/health` shows `"status":"ok"` without a certificate warning. Question: yes/no?
|
||||
7. Router, WAN, Virtual Server / Port Forwarding: TCP 8443 to the Kvitt Pi's 8443 (only after the H-1 fix is deployed); on the phone with Wi-Fi off, `https://icehack.asuscomm.com:8443/health` shows `"status":"ok"` without a certificate warning (yes/no); then the `remote_ip` check of the README's operations table shows a public address (yes/no).
|
||||
8. `/opt/kvitt/backup-now.sh` ends with "last-success: <stamp>"; `/opt/kvitt/bootstrap.sh` prints the admin invite code (first install only).
|
||||
9. New versions: `/opt/kvitt/deploy.sh v1.0.N` after the Gitea workflow pushed that tag.
|
||||
9. New versions: `/opt/kvitt/deploy.sh v1.0.N` after the Gitea workflow pushed that tag; it prints the image digests and warns if a recorded tag changed digest.
|
||||
|
||||
## 4. Owner questions (asked, unanswered)
|
||||
- None. OQ-3 is answered by placeholders (D-100); OQ-5 answered and implemented (D-099).
|
||||
- The §3 item 1 workflow result and duration.
|
||||
- The five security-review decisions of §3 item 4 (M-1 backup encryption, M-2 key form, L-3 APK download rate, `write:repository`, the 16 MP and 512-character assumptions).
|
||||
- OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1".
|
||||
- Security review L-10: should a member get `not_found` instead of `forbidden` for a device id of the other user (hides whose it is)? As built and as the spec says: `forbidden`.
|
||||
|
||||
## 5. Paused (waiting on something outside the repo)
|
||||
- Nothing.
|
||||
|
||||
## 6. Numbering
|
||||
- Decisions: D-001 to D-108 used except D-008 and D-009. Next free: **D-109**.
|
||||
- Spec version: v0.17 written; next **v0.18**.
|
||||
- Server schema version **3** (migration 0003, landed with M12e); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.17.
|
||||
- Decisions: D-001 to D-117 used, except D-008 and D-009 (never used); none reserved. Next free: **D-118**.
|
||||
- Spec version: v0.18 written; next **v0.19**.
|
||||
- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.18.
|
||||
- Sync protocol **1**, push payload **1**.
|
||||
|
||||
## 7. How the work is run
|
||||
|
||||
+55
-46
@@ -9,12 +9,12 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| D-002 | 2026-10-17 | Backup retention on the Pi: local snapshot files are deleted as soon as their upload to Drive is confirmed (`rclone lsf`), not after 30 days. Drive keeps everything. | Owner's choice; the Pi's disk is small and Drive is the archive. | SPEC §10.9 step 5; PLAN-M2c-pi |
|
||||
| D-003 | 2026-10-17 | Splitwise CSV format confirmed from two real exports (OQ-2 answered). English variant header `Date,Description,Category,Cost,Currency,Amelie Bennich,Jacob Hagstedt` (group "hemma", account "Hemma"); Swedish variant header `Datum,Beskrivning,Kategori,Kostnad,Valuta,Jacob Hagstedt,Amelie Bennich` (general group, account "Vardag", about 1750 rows). Member columns differ in order, so the importer reads member names from the header. ISO dates, point decimal, double-quoted fields with commas, trailing empty line then `Total balance` / `Totalsumma`. Payment category `Payment` / `Betalning`. All rows SEK. Swedish category names are mapped through a committed table (unknown names to `other`, counted as a warning). The partner's display name is Amelie. The CSV files stay in `export/` (git-ignored), never committed or quoted. | Real exports differ by Splitwise UI language. | SPEC §10.8, §12; PLAN-M9 (mapping notes), PLAN-M11 |
|
||||
| D-004 | 2026-10-17 | Shells and ownership. M2a creates shell files in M3, M4 and M9 paths (501 handlers, stub subcommands `bootstrap`, `pair-device`, `rotate-epoch`, a `kvitt-import` crate whose `main.rs` prints usage); M2b creates a module shell for every module of §6.2 with one empty entry composable per feature. The later milestone owns each shell from the moment it lands and writes the body. M2a owns `server/Cargo.lock`, every `Cargo.toml`, `main.rs`, `lib.rs`, config, `serve` and `migrate`, `router.rs` and its own tests (any `tests/*.rs` without the `core_`, `sync_`, `scheduler_`, `push_` prefixes); later milestones may add dependency lines to `Cargo.toml`. | PLAN-M2a and PLAN-M2b open points (a), (b), (c): the skeletons need files in paths §14.7 gave to later milestones. | SPEC §11.2, §11.3, §14.7; PLAN-M2a, PLAN-M2b, PLAN-M3, PLAN-M4, PLAN-M6, PLAN-M7a to M7f, PLAN-M9 |
|
||||
| D-005 | 2026-10-17 | The OpenAPI equality test (`server/crates/kvitt-server/tests/openapi_contract.rs`) parses both documents and compares values after eight normalisations: key order ignored; local `$ref` inlined, discriminator mappings included; `description` and `example` keywords dropped; nullable type arrays equal `oneOf` with `null`, nested `oneOf` flattened and compared as sets; `const` equals a single-value `enum`; `required: false` and `deprecated: false` dropped, path parameters always required; parameters keyed by (`in`, `name`); each operation compared on its effective security. The document-wide rules of §14.3.1 and §14.3.2 live in one place, the `CommonRules` modifier in `src/openapi.rs`. | M2a report (`fdf2093`): utoipa's output differs from the hand-written YAML only in form; D-016 needed the exact list. | SPEC §11.3, §14.8 |
|
||||
| D-005 | 2026-10-17 | The OpenAPI equality test (`server/crates/kvitt-server/tests/openapi_contract.rs`) parses both documents and compares values after eight normalisations: key order ignored; local `$ref` inlined, discriminator mappings included; `description` and `example` keywords dropped; nullable type arrays equal `oneOf` with `null`, nested `oneOf` flattened and compared as sets; `const` equals a single-value `enum`; `required: false` and `deprecated: false` dropped, path parameters always required; parameters keyed by (`in`, `name`); each operation compared on its effective security. The document-wide rules of §14.3.1 and §14.3.2 live in one place, the `CommonRules` modifier in `src/openapi.rs`. | M2a report (`06f48ef`): utoipa's output differs from the hand-written YAML only in form; D-016 needed the exact list. | SPEC §11.3, §14.8 |
|
||||
| D-006 | 2026-10-17 | The §14.5.3 token for a wrong basis-point sum is `bp_sum_not_10000`. The Rust variant `SplitError::BpSumNot10000` carries an explicit `#[serde(rename = "bp_sum_not_10000")]`, because `rename_all = "snake_case"` gives `bp_sum_not10000`. The Kotlin side must emit the same token. | M2a report: the §14.5.1 snippet would not produce the token the vectors expect. | SPEC §14.5.1, §14.5.3 |
|
||||
| D-007 | 2026-10-17 | The no-`unwrap`/`expect` rule for server non-test code is enforced per crate with `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]` at each crate root. | M2a report: a rule the gate checks instead of one reviewers remember. | CLAUDE.md (Architecture rules) |
|
||||
| D-008 | 2026-10-17 | Unused (reserved for M2a and M2b; neither needed it). | | |
|
||||
| D-009 | 2026-10-17 | Unused (reserved for M2a and M2b; neither needed it). | | |
|
||||
| D-010 | 2026-10-17 | Screenshot tests use Roborazzi 1.76.0 on Robolectric 4.17 with `@Config(sdk = [36])`; `roborazzi.test.verify=true` in `android/gradle.properties` makes `check` compare with the committed goldens; re-record with `tools/gradle-serial.sh recordRoborazziDebug`. | M2b report (`fad6fc9`): AGP's Compose Preview Screenshot Testing plugin was still alpha. | SPEC §6.2, §11.2, §11.3; android/README.md |
|
||||
| D-010 | 2026-10-17 | Screenshot tests use Roborazzi 1.76.0 on Robolectric 4.17 with `@Config(sdk = [36])`; `roborazzi.test.verify=true` in `android/gradle.properties` makes `check` compare with the committed goldens; re-record with `tools/gradle-serial.sh recordRoborazziDebug`. | M2b report (`c277ee3`): AGP's Compose Preview Screenshot Testing plugin was still alpha. | SPEC §6.2, §11.2, §11.3; android/README.md |
|
||||
| D-011 | 2026-10-17 | `resolveRange` with `CUSTOM` and a null start or end throws `IncompleteCustomRangeException` (subclass of `IllegalArgumentException`) declared in `:domain`; callers map it to `validation_failed`. `ALL_TIME` with the earliest expense after today gives `today`..`today`. Completes D-018. | M2b report: D-018 left the name to M2b. | SPEC §11.3, §14.5.4 |
|
||||
| D-012 | 2026-10-17 | Room 3 (`androidx.room3` 3.0.3, KSP only, `SQLiteDriver` with `sqlite-bundled`; migrations take an `SQLiteConnection`) instead of Room 2. M2b created the Room entities and `KvittDatabase` version 1 in `:data`; the exported schema `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` is a contract file (§14.8). M5 builds on the existing entities; changing them or the exported schema is a contract change. | M2b report: §14.2 named Room without a major version; M2b chose Room 3 and created the entities and version 1 with the skeleton, so §11.2 and PLAN-M5 had to follow. | SPEC §6.2, §11.2 (M2b, M5), §11.3, §14.2, §14.2.3, §14.7, §14.8; PLAN-M5 |
|
||||
| D-013 | 2026-10-17 | Android unit tests run with `--add-exports java.base/jdk.internal.access` and `--add-opens java.base/java.io` on JDK 25 for Robolectric, set in `build-logic` for every Android module. | M2b report: needed by Robolectric on the pinned JDK 25. | SPEC §6.2 |
|
||||
@@ -30,66 +30,66 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| D-023 | 2026-10-17 | M2c-pi: ~~key-based SSH to the Kvitt Pi and the Gitea credentials are HUMAN steps; the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint;~~ (revised by D-100: no SSH from the Mac to the Pis and no agent allowances; the owner runs the `infra/pi/` bundle; the Gitea credentials stay a HUMAN step); the Gitea Actions workflow lives at `.gitea/workflows/`, owned by M2c-pi; secret files on the Pi (`ha_ssh_key`, `ha_known_hosts`, ~~`rclone.conf`~~) mode 0600 owned by uid 1000, the uid of the `cert-sync` ~~and `backup`~~ container (revised by D-105: `backup` runs as uid 10001 and `rclone.conf` is owned by 10001:10001); adding `gitea.lan` to `insecure-registries` is its own HUMAN step. | PLAN-M2c-pi open points (a) to (c). | SPEC §10.2, §10.3, §11.2, §11.3, §14.7; PLAN-M2c-pi |
|
||||
| D-024 | 2026-10-17 | Release keystore: `keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000`, created by the owner ~~and kept outside the repository~~ (was `android/keystore/release.jks`; revised by D-101: at the repository root, git-ignored by `*.jks`); `local.properties` holds its absolute path. | §10.6 did not fix the key parameters (PLAN-M10 H-1); a keystore outside every checkout cannot be committed or deleted with a worktree. | SPEC §10.6, §11.2, §11.3; PLAN-M10 |
|
||||
| D-025 | 2026-10-17 | Production Splitwise import (M11): a backup first; user ids resolved by display name from the database; CSVs copied to the Pi into `/opt/kvitt/import/`, bind-mounted read-only into `server` as `/import`; the import runs as `docker compose run --rm --entrypoint kvitt-import server ...` with the server stopped (`--entrypoint` because the image's entrypoint is `kvitt-server`); CSVs deleted afterwards. The category table stays at `server/crates/kvitt-import/data/splitwise_categories.csv`. | PLAN-M11 open points (a) to (c); PLAN-M9 table location. | SPEC §10.2, §10.8, §11.2, §11.3; PLAN-M2c-pi, PLAN-M9, PLAN-M11 |
|
||||
| D-026 | 2026-10-17 | A 409 `cursor_ahead` on pull is treated as epoch recovery: cursor 0, epoch cleared, every local expense and settlement marked pending with a new `pending_mutation_id`, cycle restarted. | M5 report (`c28c52b`): a cursor ahead of the server means the server lost rows, as after a restore without `rotate-epoch`. | SPEC §14.4.2 |
|
||||
| D-027 | 2026-10-17 | A `clock_skew` rejection gives the row a new `pending_mutation_id` and the row stays pending (the server stored the rejection under the old id and would replay it). Orchestrator addition: on the first sync attempt after a `clock_skew` rejection, the phone re-stamps `updated_at` of each such pending row to its current clock before pushing, so a corrected clock heals the row. ~~The re-stamp is not in `:data` at `c28c52b`: contract request for M8 or a small `:data` follow-up (HANDOFF open work).~~ (Revised by D-094: implemented by M12e as a re-stamp of pending rows stamped later than now, at the start of a cycle with `clock_skew` set.) | M5 report; without the re-stamp a row stamped by a wrong clock would stay rejected after the clock is fixed. | SPEC §14.4.5 |
|
||||
| D-026 | 2026-10-17 | A 409 `cursor_ahead` on pull is treated as epoch recovery: cursor 0, epoch cleared, every local expense and settlement marked pending with a new `pending_mutation_id`, cycle restarted. | M5 report (`8f0e156`): a cursor ahead of the server means the server lost rows, as after a restore without `rotate-epoch`. | SPEC §14.4.2 |
|
||||
| D-027 | 2026-10-17 | A `clock_skew` rejection gives the row a new `pending_mutation_id` and the row stays pending (the server stored the rejection under the old id and would replay it). Orchestrator addition: on the first sync attempt after a `clock_skew` rejection, the phone re-stamps `updated_at` of each such pending row to its current clock before pushing, so a corrected clock heals the row. ~~The re-stamp is not in `:data` at `8f0e156`: contract request for M8 or a small `:data` follow-up (HANDOFF open work).~~ (Revised by D-094: implemented by M12e as a re-stamp of pending rows stamped later than now, at the start of a cycle with `clock_skew` set.) | M5 report; without the re-stamp a row stamped by a wrong clock would stay rejected after the clock is fixed. | SPEC §14.4.5 |
|
||||
| D-028 | 2026-10-17 | Periodic sync work is named `kvitt-sync-periodic` (policy `KEEP`); one-off work keeps `kvitt-sync` (`APPEND_OR_REPLACE`). | M5 report: WorkManager cannot share one unique name between periodic and one-off work. | SPEC §14.4.1, AC-15.3 |
|
||||
| D-029 | 2026-10-17 | A REST call that fails on the network returns `OnlineResult.Failed("network", null)`; `network` is the one allowed code outside §14.3.2. | M5 report: the UI must tell "Could not reach the server" (§9.9) from `Offline` (call not attempted). | SPEC §14.2.4 |
|
||||
| D-030 | 2026-10-17 | Picture version after a removal: E-10 deletes the JPEGs and leaves an empty `<v>-removed` marker in `pictures/<user_id>/`; the next upload uses the highest version on disk plus one, so caches keyed by (`user_id`, `picture_version`) never show a removed picture; the first upload is version 1. | M3 report (`503cee5`): `picture_version` is null after a removal, so the next version needs a record. | SPEC AC-4.4, §10.4, E-10 |
|
||||
| D-030 | 2026-10-17 | Picture version after a removal: E-10 deletes the JPEGs and leaves an empty `<v>-removed` marker in `pictures/<user_id>/`; the next upload uses the highest version on disk plus one, so caches keyed by (`user_id`, `picture_version`) never show a removed picture; the first upload is version 1. | M3 report (`7869ec4`): `picture_version` is null after a removal, so the next version needs a record. | SPEC AC-4.4, §10.4, E-10 |
|
||||
| D-031 | 2026-10-17 | Failed invite accept (AC-2.12): any 4xx answer other than 429 counts; after 10 failures further accepts from that IP are refused with `rate_limited` until 60 minutes after the first counted failure; windows are fixed from the first counted request. | M3 report: AC-2.12 did not define "failed" or the window. | SPEC AC-2.12, §8.6 |
|
||||
| D-032 | 2026-10-17 | Rate-limit keying (§8.6): per device by the hash of the bearer token, no database lookup; a 401 also counts against the client IP; tokens never accepted are refused once the IP is over its limit; `KVITT_TRUSTED_PROXY` re-resolved at most every 60 s; the client IP is the last `X-Forwarded-For` entry from the trusted proxy; `serve` must use `into_make_service_with_connect_info` (being added by M4). | M3 report: keying and proxy handling were unspecified; the limiter must not cost a database query. | SPEC §8.6, §10.4, §11.3 |
|
||||
| D-033 | 2026-10-17 | Resume rule (§9.5): `last_generated_period` moves forward only when the latest past period is at or after `start_period`, so resuming a not-yet-started template keeps its start. | M3 report: the v0.4 rule would skip a future start on resume. | SPEC §9.5 |
|
||||
| D-034 | 2026-10-17 | Storage normalisation: strings stored trimmed, `notes` included; PNG and WebP transparency flattened onto white; `fcm_token` non-empty or null (`""` is `invalid_request`); revoking a device clears its `fcm_token`; archive or unarchive in the state already held returns the row unchanged with no new `server_version`; a new category's `sort_order` is the maximum over non-deleted categories plus 10. | M3 report: details the spec left open. | SPEC §8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26 |
|
||||
| D-035 | 2026-10-18 | A mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable (not a canonical lowercase hyphenated UUID, or an `entity_type` other than `expense` or `settlement`) fails the whole `POST /sync` with 400 `invalid_request`, checked before the transaction; every other shape error is a per-mutation `rejected` / `validation_failed` (§14.4.4 step 3). | M4 report (`bd6f10a`): without those keys no per-mutation result (keyed by `mutation_id`, `entity_type`, `entity_id`) can be formed or stored in `sync_mutations`. | SPEC §14.4.4 |
|
||||
| D-036 | 2026-10-18 | `POST /sync` with 0 mutations is 422 `batch_too_large`; the §14.3.2 meaning reads "Number of mutations in `POST /sync` outside 1 to 100". | M4 report (`bd6f10a`): §9.6 said 1 to 100 but §14.3.2 named only "more than 100". | SPEC §9.6, §14.3.2, §14.4.4 |
|
||||
| D-037 | 2026-10-18 | A delete of a never-synced row (§14.4.4 step 7, no stored row) runs the full step-8 validation (field rules, domain rules, references), so the stored tombstone satisfies the schema. Every rejection carries the stored row or `null` as `current`, at steps 3 and 4 too. Step 11 keeps the stored `created_at` (a pushed one is ignored on update). Pushed ids must be canonical lowercase hyphenated UUIDs; another spelling of a valid UUID is not accepted. | M4 report (`bd6f10a`): v0.5 validated only references for such a tombstone, left `current` open at steps 3 and 4, and did not say whether `created_at` may change. | SPEC §14.4.3, §14.4.4 |
|
||||
| D-038 | 2026-10-18 | FCM retry is one send plus up to three retries after 1 s, 5 s, 25 s (four sends at most) on 429, 5xx and network errors. On 401 the cached access token is dropped and the message is not retried; other 4xx are not retried. Retries run inside the single sender task, blocking it up to 31 s per device; accepted for two users. | M4 report (`bd6f10a`): "3 attempts with delays 1 s, 5 s, 25 s" was ambiguous (three delays need four sends) and 4xx handling was open. | SPEC §14.6 |
|
||||
| D-039 | 2026-10-18 | `sync_mutations` pruning deletes rows whose `received_at` is strictly older than 90 days (a row exactly 90 days old is kept); PLAN-M4's "90 days and older" corrected. Seam change to D-015: `tasks::start` returns the `AppState` with the real push queue installed and `serve` builds the router from it; `AppState::new` defaults to `SqlAuditWriter`; `NoopAuditWriter` is removed; `NoopPushQueue` stays for push disabled. | M4 report (`bd6f10a`): "older than 90 days" (D-021) and PLAN-M4 disagreed at the boundary; the push queue must exist before the router is built. | SPEC §6.3, §14.1, §14.4.9, §14.6; PLAN-M4; server/README.md |
|
||||
| D-035 | 2026-10-18 | A mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable (not a canonical lowercase hyphenated UUID, or an `entity_type` other than `expense` or `settlement`) fails the whole `POST /sync` with 400 `invalid_request`, checked before the transaction; every other shape error is a per-mutation `rejected` / `validation_failed` (§14.4.4 step 3). | M4 report (`15056dc`): without those keys no per-mutation result (keyed by `mutation_id`, `entity_type`, `entity_id`) can be formed or stored in `sync_mutations`. | SPEC §14.4.4 |
|
||||
| D-036 | 2026-10-18 | `POST /sync` with 0 mutations is 422 `batch_too_large`; the §14.3.2 meaning reads "Number of mutations in `POST /sync` outside 1 to 100". | M4 report (`15056dc`): §9.6 said 1 to 100 but §14.3.2 named only "more than 100". | SPEC §9.6, §14.3.2, §14.4.4 |
|
||||
| D-037 | 2026-10-18 | A delete of a never-synced row (§14.4.4 step 7, no stored row) runs the full step-8 validation (field rules, domain rules, references), so the stored tombstone satisfies the schema. Every rejection carries the stored row or `null` as `current`, at steps 3 and 4 too. Step 11 keeps the stored `created_at` (a pushed one is ignored on update). Pushed ids must be canonical lowercase hyphenated UUIDs; another spelling of a valid UUID is not accepted. | M4 report (`15056dc`): v0.5 validated only references for such a tombstone, left `current` open at steps 3 and 4, and did not say whether `created_at` may change. | SPEC §14.4.3, §14.4.4 |
|
||||
| D-038 | 2026-10-18 | FCM retry is one send plus up to three retries after 1 s, 5 s, 25 s (four sends at most) on 429, 5xx and network errors. On 401 the cached access token is dropped and the message is not retried; other 4xx are not retried. Retries run inside the single sender task, blocking it up to 31 s per device; accepted for two users. | M4 report (`15056dc`): "3 attempts with delays 1 s, 5 s, 25 s" was ambiguous (three delays need four sends) and 4xx handling was open. | SPEC §14.6 |
|
||||
| D-039 | 2026-10-18 | `sync_mutations` pruning deletes rows whose `received_at` is strictly older than 90 days (a row exactly 90 days old is kept); PLAN-M4's "90 days and older" corrected. Seam change to D-015: `tasks::start` returns the `AppState` with the real push queue installed and `serve` builds the router from it; `AppState::new` defaults to `SqlAuditWriter`; `NoopAuditWriter` is removed; `NoopPushQueue` stays for push disabled. | M4 report (`15056dc`): "older than 90 days" (D-021) and PLAN-M4 disagreed at the boundary; the push queue must exist before the router is built. | SPEC §6.3, §14.1, §14.4.9, §14.6; PLAN-M4; server/README.md |
|
||||
| D-040 | 2026-10-17 | A push refused as a whole (400 `invalid_request`, 413 `payload_too_large`, 409 `household_incomplete`, 422 `batch_too_large`) counts as a batch with no progress: the cycle goes on to pull and records `last_error_code`; 5xx and 429 retry with backoff; 401 and 426 stop. (Proposed by M5 as D-030, renumbered.) | M5 report: §14.4.5 covered only network errors, 5xx and 401. | SPEC §14.4.5 |
|
||||
| D-041 | 2026-10-17 | `:data`'s `DataModule` provides implementations only; the interface bindings live in `DataBindingsModule` (`@DisableInstallInCheck`), which `:app` includes ~~for release so the debug fakes do not duplicate bindings~~ (revised by D-081: `:app` includes it in both variants; the debug fakes module is gone). (M5's D-031, renumbered.) | M5 report: Hilt fails on duplicate bindings when debug binds fakes. | SPEC §6.2, §11.3 (M8); PLAN-M8 |
|
||||
| D-042 | 2026-10-17 | Room tests run on Robolectric with the framework SQLite driver; migration tests build from the exported schema JSON rather than `room3-testing`. (M5's D-032, renumbered.) | M5 report: no device needed for the gate. | SPEC §6.2 |
|
||||
| D-043 | 2026-10-17 | Two extra DataStore keys: the pairing status in `auth.preferences_pb`, and the installed version code that received a 426 in `prefs.preferences_pb` (syncing stops until the installed version changes). The token is encrypted with Keystore key `kvitt_token_key`, AES-256-GCM (as §8.2). (M5's D-033, renumbered.) | M5 report. | SPEC §8.2, §14.2.2, §14.4.5 |
|
||||
| D-044 | 2026-10-18 | Nine Swedish Splitwise category names, found unmapped in the importer rehearsal on the real Swedish export, are added to the §10.8 table and to `server/crates/kvitt-import/data/splitwise_categories.csv` with the key of their English equivalent: Städning → `utilities` (Cleaning); Verktyg - Övrigt → `utilities` (Utilities - Other); Tjänster → `home` (Services); Cykel → `transport` (Bicycle); Flyg → `travel` (Plane); Spel → `entertainment` (Games); Kläder → `shopping` (Clothing); Livet - Övrigt → `other` (Life - Other); Utbildning → `other` (Education). The test `category_table_matches_the_spec` keeps the two in step. | The rehearsal of 2026-10-18 left 21 rows of the Vardag file as `other`, counted unmapped, only because these names were missing from D-003's table. | SPEC §10.8; `server/crates/kvitt-import/data/splitwise_categories.csv` |
|
||||
| D-045 | 2026-10-18 | Import reconciliation. Inside the import transaction, after the rows are written, each user's balance of the whole account (`net(u)`, §9.3) must equal the sum of that member's values in the file's totals rows (`Total balance` / `Totalsumma`), one totals row allowed per currency, a non-SEK one converted with its `--fx` rate. A mismatch or a missing totals row rolls the run back and exits 4; a dry run reports the same. The comparison covers the whole account, so importing into an account that already holds other rows rolls back. When any `--fx` conversion took place, a mismatch is reported in the summary but accepted (per-value rounding can make an exact match impossible). | M9 report (`ab298ab`): v0.6 ignored the totals row, and M11's gate ("imported balances equal Splitwise's totals") is cheapest checked by the importer itself, before anything is committed. | SPEC §10.8, AC-17.5, §11.2 (M11) |
|
||||
| D-046 | 2026-10-18 | Importer CLI semantics. `--user <Splitwise name>=<user>` takes a user id or an exact display name that exactly one user has; the two arguments must name the two household users. The importer never creates or migrates a database and requires `meta.schema_version` = `1` (revised by D-054: the schema version the server's migrations produce, now `2`). A dry run runs the full transaction and rolls it back. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import (header, invalid rows, non-SEK without `--fx`), 4 reconciliation mismatch. The import key uses an existing account's stored name, so a re-run with another spelling finds its rows. An empty description or one over 1000 code points makes the row invalid; a description over 100 code points is cut with the full text in `notes`; settlement notes are cut to 200 code points; both nets zero means the owner pays in mode `full`. Idempotency uses the existing `import_key` columns; no schema change. | M9 report (`ab298ab`): §10.8 left the user lookup, database handling, dry-run mechanics, exit codes and these edge cases open. | SPEC §10.8, AC-17.5; server/README.md |
|
||||
| D-045 | 2026-10-18 | Import reconciliation. Inside the import transaction, after the rows are written, each user's balance of the whole account (`net(u)`, §9.3) must equal the sum of that member's values in the file's totals rows (`Total balance` / `Totalsumma`), one totals row allowed per currency, a non-SEK one converted with its `--fx` rate. A mismatch or a missing totals row rolls the run back and exits 4; a dry run reports the same. The comparison covers the whole account, so importing into an account that already holds other rows rolls back. When any `--fx` conversion took place, a mismatch is reported in the summary but accepted (per-value rounding can make an exact match impossible). | M9 report (`50339ae`): v0.6 ignored the totals row, and M11's gate ("imported balances equal Splitwise's totals") is cheapest checked by the importer itself, before anything is committed. | SPEC §10.8, AC-17.5, §11.2 (M11) |
|
||||
| D-046 | 2026-10-18 | Importer CLI semantics. `--user <Splitwise name>=<user>` takes a user id or an exact display name that exactly one user has; the two arguments must name the two household users. The importer never creates or migrates a database and requires `meta.schema_version` = `1` (revised by D-054: the schema version the server's migrations produce, now `2`). A dry run runs the full transaction and rolls it back. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import (header, invalid rows, non-SEK without `--fx`), 4 reconciliation mismatch. The import key uses an existing account's stored name, so a re-run with another spelling finds its rows. An empty description or one over 1000 code points makes the row invalid; a description over 100 code points is cut with the full text in `notes`; settlement notes are cut to 200 code points; both nets zero means the owner pays in mode `full`. Idempotency uses the existing `import_key` columns; no schema change. | M9 report (`50339ae`): §10.8 left the user lookup, database handling, dry-run mechanics, exit codes and these edge cases open. | SPEC §10.8, AC-17.5; server/README.md |
|
||||
| D-047 | 2026-10-18 | Visual direction: U1's direction 3 "Kalas" (cards, very round shapes, a solid category-colour tile on every expense, the seesaw balance) with the muted palette of `docs/ux/directions/3-kalas/` (owner approved 2026-10-18). Typeface per D-020 (Material 3 default); Kalas's preview font Nunito is dropped. U2's `design-spec.md` carries the tokens. | Owner's pick (UX-1); muted because bright colours tire over years of daily use. | SPEC §9.7, §13.4 |
|
||||
| D-048 | 2026-10-18 | Navigation stays at three destinations (Översikt, Statistik, Inställningar); §13.2 is binding. An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth tab and not taken for v1. | Owner's answer (UX-2). | SPEC §13.2 |
|
||||
| D-049 | 2026-10-18 | Category suggestion (§9.11 CS-1 to CS-7): for a new expense, while the category was not picked by hand in this editor session, the editor selects (1) the category used most often among expenses with the same normalised description, (2) else most often among expenses with the same first token, ties by the most recent expense in list order, then category id, (3) else the longest matching entry of the fixed keyword table `contracts/category-keywords.csv`, (4) else the AC-8.6 default. Recomputed on every description and account change; no marker; editing never changes a category. `ExpenseRepository.suggestCategory(accountId, description)` in `:data`; pure `suggestCategory(description, history, keywords, activeCategories, fallback, unicode: UnicodeText): CategoryId?` in `:domain` (Kotlin only) with `suggest_category.json`. `UnicodeText` (`nfc`, `isLetter(codePoint)`, `isDigit(codePoint)` meaning Nd) is an interface in `:domain` that `:data` binds with `java.text.Normalizer` and `Character`, because pure modules cannot use `java.*`; the records `SuggestionHistoryEntry(description, categoryId, expenseDate, createdAt)`, `CategoryKeyword(keyword, match, defaultKey: String)`, `ActiveCategory(id, defaultKey: String?)` and the enum `KeywordMatch` live in `:domain`, with default keys as strings. History ties: count, then the latest (`expense_date`, `created_at`), then the lower category id; code points walked by surrogate pairs; keywords rank by code-point length, then table order. As landed by M12a (`6b60ef7`). | Owner (UX-3): most expenses repeat the same shops; fewer taps in the ten-second flow (§13.1). Replay of the household's 1 775 Splitwise expenses: 79.7 % right versus 30.5 % for the AC-8.6 default alone; "most often" beats "most recent" by about one point (`docs/ux/category-suggestion.md`). | SPEC AC-8.6, §9.11, §13.3 (S-7), §14.2.4, §14.5.5, §14.5.6, §14.7, §14.8 |
|
||||
| D-049 | 2026-10-18 | Category suggestion (§9.11 CS-1 to CS-7): for a new expense, while the category was not picked by hand in this editor session, the editor selects (1) the category used most often among expenses with the same normalised description, (2) else most often among expenses with the same first token, ties by the most recent expense in list order, then category id, (3) else the longest matching entry of the fixed keyword table `contracts/category-keywords.csv`, (4) else the AC-8.6 default. Recomputed on every description and account change; no marker; editing never changes a category. `ExpenseRepository.suggestCategory(accountId, description)` in `:data`; pure `suggestCategory(description, history, keywords, activeCategories, fallback, unicode: UnicodeText): CategoryId?` in `:domain` (Kotlin only) with `suggest_category.json`. `UnicodeText` (`nfc`, `isLetter(codePoint)`, `isDigit(codePoint)` meaning Nd) is an interface in `:domain` that `:data` binds with `java.text.Normalizer` and `Character`, because pure modules cannot use `java.*`; the records `SuggestionHistoryEntry(description, categoryId, expenseDate, createdAt)`, `CategoryKeyword(keyword, match, defaultKey: String)`, `ActiveCategory(id, defaultKey: String?)` and the enum `KeywordMatch` live in `:domain`, with default keys as strings. History ties: count, then the latest (`expense_date`, `created_at`), then the lower category id; code points walked by surrogate pairs; keywords rank by code-point length, then table order. As landed by M12a (`aa765bb`). | Owner (UX-3): most expenses repeat the same shops; fewer taps in the ten-second flow (§13.1). Replay of the household's 1 775 Splitwise expenses: 79.7 % right versus 30.5 % for the AC-8.6 default alone; "most often" beats "most recent" by about one point (`docs/ux/category-suggestion.md`). | SPEC AC-8.6, §9.11, §13.3 (S-7), §14.2.4, §14.5.5, §14.5.6, §14.7, §14.8 |
|
||||
| D-050 | 2026-10-18 | S-1 has a required "Ditt namn" display-name field (1 to 40 code points, §9.6) on a first pairing, hidden in the "Para ihop telefonen igen" variant; a partner confirms it again on S-2 (AC-2.10). | UX-4: AC-2.4 sends a display name with the accept call, but S-1 listed no field for it. Owner OK. | SPEC AC-2.4, §13.3 (S-1) |
|
||||
| D-051 | 2026-10-18 | The §9.7 colour hex values are replaced by the muted Kalas values when U2 reports; the token names are fixed now, the two new ones being `slate` (insurance) and `amber` (fees), whose values are `TODO(U2)`. M6 starts only after the values have landed in §9.7. Fulfilled in SPEC v0.9 (2026-10-18): the §9.7 values are those of `docs/ux/design-spec.md` §2.3 (U2, landed `16c4d01`), `slate` `#5E2D4E` / `#B0719A`, `amber` `#937D63` / `#DFCFC0`, light `brown` `#6B5244`. | UX-5: several provisional values (light orange, yellow, lime) fail contrast as icon colours on tinted containers; UX-8 needs two more tokens. | SPEC §9.7, §13.4 |
|
||||
| D-051 | 2026-10-18 | The §9.7 colour hex values are replaced by the muted Kalas values when U2 reports; the token names are fixed now, the two new ones being `slate` (insurance) and `amber` (fees), whose values are `TODO(U2)`. M6 starts only after the values have landed in §9.7. Fulfilled in SPEC v0.9 (2026-10-18): the §9.7 values are those of `docs/ux/design-spec.md` §2.3 (U2, landed `6da6b1b`), `slate` `#5E2D4E` / `#B0719A`, `amber` `#937D63` / `#DFCFC0`, light `brown` `#6B5244`. | UX-5: several provisional values (light orange, yellow, lime) fail contrast as icon colours on tinted containers; UX-8 needs two more tokens. | SPEC §9.7, §13.4 |
|
||||
| D-052 | 2026-10-18 | Smaller UX rules: saving an expense shows "Utgiften sparades" without "Ångra" (S-7); an archived account's detail hides the add-expense FAB and keeps settling (S-5); the per-account settle-up keeps the direction swap and an editable amount, its one account row fixed (S-9); avatar initials use two colour tokens `avatar-1`, `avatar-2`, the slot a stable function of the user id (AC-4.5). The owner approved the Swedish wording "Ni är kvitt", "Gör upp", "Du lånade ut / Du lånade", "Inte inblandad" (AC-8.10; the copy lives in `docs/ux/strings.csv`). U2 renames the `strings.csv` keys to the feature prefixes of D-017. | UX-6, found during U1: an undo would be an audited create and delete that also notifies the partner; archived accounts take no new expenses (AC-6.4); §9.4 allows per-account settle-ups either way and AC-11.4 says the amount "defaults to". | SPEC AC-4.5, AC-8.10, §13.3 (S-5, S-7, S-9) |
|
||||
| D-053 | 2026-10-18 | Splitwise categories are mapped as finely as the fourteen categories allow: Rent, Mortgage, Hyra, Avbetalning/Amortering → `housing`; Insurance, Försäkringar → `insurance`; Childcare, Barnomsorg → `children`; Taxes, Skatter → `fees`; the Swedish labels El, Värme/gas, Vatten, Sophämtning (`utilities`) and Husdjur (`home`) added by analogy, to be checked against Splitwise (an unseen name only stays unmapped). The §10.8 table and `server/crates/kvitt-import/data/splitwise_categories.csv` change together (M12b). | UX-7 (owner, 2026-10-18): §10.8 collapsed Försäkringar (26 rows), Barnomsorg (73) and Skatter (19) into `other` and Hyra (106) and Avbetalning/Amortering (48) into `home`; "Hemförsäkring" must not end up in Övrigt. | SPEC §10.8 |
|
||||
| D-054 | 2026-10-18 | Fourteen default categories: `housing` Boende (`key`, `indigo`, 25), `insurance` Försäkringar (`shield`, `slate`, 45), `children` Barn (`child_care`, `lime`, 85), `fees` Avgifter & skatter (`account_balance`, `amber`, 95) join the ten; `home` keeps furniture, supplies, maintenance, electronics and services. Ids `01920000-0000-7000-8000-000000000011` to `…014`, `created_at` `2026-10-18T00:00:00.000Z`, `server_version` from the global sequence. Server schema 2 through migration 0002, which rebuilds `categories` (the v1 `CHECK` lists ten keys) under `PRAGMA defer_foreign_keys`, inserts the four rows and sets `schema_version` 2; its text is locked as `contracts/schema/migrations/0002_categories_v2.sql` (§14.1.1); `server-v1.sql` stays byte-locked. Icons 33 (`key`, `shield`, `account_balance`), colours 14, `DefaultCategoryKey` and `ColorToken` extended, OpenAPI enum, E-1 `schema_version` 2; the importer requires schema 2 (revises D-046's "1"). Room schema unchanged (rows arrive through sync). Carried out by milestone group M12 (M12a contracts, then M12b server and M12c Android in parallel; M6 and M7c after M12c). | UX-8 (owner, 2026-10-18): the household's history has these as large separate groups (Hyra + Amortering 154 rows, Barnomsorg 73, Försäkringar 26, Skatter 19) that were collapsed into Hem and Övrigt; separate categories make analytics and the suggestion meaningful. M3, M5 and M9 landed with ten defaults, so this is a contract change against `main`. | SPEC AC-7.2, §6.2, §9.7, §10.8, §11, §13.3 (S-15), §14.1, §14.1.1, §14.2.4, §14.3.3, §14.3.4 (E-1), §14.7, §14.8 |
|
||||
| D-055 | 2026-10-18 | The keyword table reaches the phone by a build-time copy: `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords` copy `contracts/category-keywords.csv` into an AGP generated resource directory; a missing file fails the build. 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); no committed copy. A `:data` test asserts byte identity with the contract file and the §14.8 rules. Supersedes the "byte-identical copy at `android/data/src/main/resources`" wording of v0.8. | M12c report (`e499107`): a committed copy can drift from the contract file; a build-time copy cannot. | SPEC §11.2 (M12c), §14.2.4, §14.7, §14.8; PLAN-M12c |
|
||||
| D-056 | 2026-10-18 | `:domain` exposes the pure `parseCategoryKeywords(csv: String): List<CategoryKeyword>`: header `keyword,match,default_key,source`, entries in file order, `IllegalArgumentException` on a malformed table; shared by `:data` and `:core:fakes`. | M12c report (`e499107`): two callers needed the same parser; `:domain` is the shared pure module. | SPEC §14.5.6 |
|
||||
| D-057 | 2026-10-18 | `DefaultCategoryKey` in `:core:model` 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. | M12b (`2e2ebe7`) and M12c (`e499107`) reports: the declaration orders differ by side; enums compare as sets on the wire. | SPEC §14.2.4 |
|
||||
| D-058 | 2026-10-18 | The server's test harnesses seed the two users after the fourteen category seeds, so they have `server_version` 15 and 16. `contracts/examples/E-7-me/response.json` shows 15 and 16 (was 11 and 12, from schema 1's ten seeds), and the E-7 test compares `server_version` with the example again instead of treating it as volatile. | M12b report (`2e2ebe7`): migration 0002 moved the users' versions; the example should match what the server produces. | SPEC §14.3.5; `contracts/examples/E-7-me/response.json`, `server/crates/kvitt-server/tests/core_api/me.rs` |
|
||||
| D-055 | 2026-10-18 | The keyword table reaches the phone by a build-time copy: `:data:copyCategoryKeywords` and `:core:fakes:copyCategoryKeywords` copy `contracts/category-keywords.csv` into an AGP generated resource directory; a missing file fails the build. 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); no committed copy. A `:data` test asserts byte identity with the contract file and the §14.8 rules. Supersedes the "byte-identical copy at `android/data/src/main/resources`" wording of v0.8. | M12c report (`b09f322`): a committed copy can drift from the contract file; a build-time copy cannot. | SPEC §11.2 (M12c), §14.2.4, §14.7, §14.8; PLAN-M12c |
|
||||
| D-056 | 2026-10-18 | `:domain` exposes the pure `parseCategoryKeywords(csv: String): List<CategoryKeyword>`: header `keyword,match,default_key,source`, entries in file order, `IllegalArgumentException` on a malformed table; shared by `:data` and `:core:fakes`. | M12c report (`b09f322`): two callers needed the same parser; `:domain` is the shared pure module. | SPEC §14.5.6 |
|
||||
| D-057 | 2026-10-18 | `DefaultCategoryKey` in `:core:model` 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. | M12b (`6eaccec`) and M12c (`b09f322`) reports: the declaration orders differ by side; enums compare as sets on the wire. | SPEC §14.2.4 |
|
||||
| D-058 | 2026-10-18 | The server's test harnesses seed the two users after the fourteen category seeds, so they have `server_version` 15 and 16. `contracts/examples/E-7-me/response.json` shows 15 and 16 (was 11 and 12, from schema 1's ten seeds), and the E-7 test compares `server_version` with the example again instead of treating it as volatile. | M12b report (`6eaccec`): migration 0002 moved the users' versions; the example should match what the server produces. | SPEC §14.3.5; `contracts/examples/E-7-me/response.json`, `server/crates/kvitt-server/tests/core_api/me.rs` |
|
||||
| D-059 | 2026-10-18 | UX-9 accepted as a contract change: `ExpenseEditorOptions.frequentCategoryIds: Map<AccountId, List<CategoryId>>` (per active account up to 5 active categories by use count in that account over the whole Room copy, ties by `sort_order`, unused ones left out; read once on open and on an account change) and `CategoryRepository.deletable(id): Flow<Boolean>` (false for a default or a category used by a non-deleted expense or template, AC-7.6; the server stays the authority), with the readings, UI text choice and fallbacks of §14.2.4 "Changes in v0.9". Implemented in `:core:model`, `:data` and `:core:fakes` by the new milestone M12d, run in parallel with M6; M7c and M7f consume it, and M7a to M7f depend on M6 and M12d. | U2 (`docs/ux/proposed-decisions.md` UX-9): S-7's `CategoryQuickRow` and S-15's category editor have binding states the UI contract did not carry. | SPEC §11.1, §11.2, §11.3, §14.2.4, §14.7; PLAN-M12d, PLAN-M7c, PLAN-M7f |
|
||||
| D-060 | 2026-10-18 | `ExpenseEditorOptions.frequentCategoryIds` holds only categories actually used in each active account, so a list may have fewer than five entries or be empty. Filling up to five from `sort_order` and placing the selected category fifth stays in the editor (UX-9, `docs/ux/design-spec.md` §3.7). Not a contract change. As landed by M12d (`bdba9d3`). | M12d report: D-059's "at most 5, unused ones left out" left open whether the repository or the editor fills the row; keeping the fill in the editor keeps the repository a plain usage count. | SPEC §14.2.4 ("Readings fixed in v0.10"), §11.2, §11.3 |
|
||||
| D-060 | 2026-10-18 | `ExpenseEditorOptions.frequentCategoryIds` holds only categories actually used in each active account, so a list may have fewer than five entries or be empty. Filling up to five from `sort_order` and placing the selected category fifth stays in the editor (UX-9, `docs/ux/design-spec.md` §3.7). Not a contract change. As landed by M12d (`557af7e`). | M12d report: D-059's "at most 5, unused ones left out" left open whether the repository or the editor fills the row; keeping the fill in the editor keeps the repository a plain usage count. | SPEC §14.2.4 ("Readings fixed in v0.10"), §11.2, §11.3 |
|
||||
| D-061 | 2026-10-18 | `frequentCategoryIds` has a key for every active account (an empty list when nothing was used there) and no key for an archived account. After use count (descending) and `sort_order` (ascending), ties fall back to the category id (ascending), so the order is identical in Room and in the fakes. | M12d report: without a last tie-break two categories with equal count and `sort_order` (custom categories can share one) could order differently in `:data` and `:core:fakes`. | SPEC §14.2.4 |
|
||||
| D-062 | 2026-10-18 | `CategoryRepository.deletable(id)` returns false for an unknown or already deleted id. Archived categories follow the same AC-7.6 rule as active ones. In `:core:fakes`, `delete` and `deletable` share one rule function. | M12d report: D-059 did not cover unknown, deleted or archived ids; one rule function keeps the fake from offering a delete it then refuses. | SPEC §14.2.4, AC-7.6 |
|
||||
| D-063 | 2026-10-18 | Charts (S-11) are drawn with Compose `Canvas` in `:core:design`, not Vico. No chart dependency is added; ~~Vico stays in `android/gradle/libs.versions.toml` unused and can be removed in a later cleanup.~~ (Vico removed from the catalog on 2026-10-18, `2b4e21c`; SPEC v0.14.) Takes the fallback §6.2 already allowed. As landed by M6 (`3080804`). | M6 report: Vico 3.3.1 cannot give design-spec §3.10's one focusable TalkBack node per bar with its own sentence and tap target, 2 dp gaps between segments, the 10/4 corner radii on the whole bar (not per segment; 4/2 since D-097) and a y-axis gutter that grows at 200 % font scale. | SPEC §6.2 (Charts, module table), §11.2, §11.3 |
|
||||
| D-063 | 2026-10-18 | Charts (S-11) are drawn with Compose `Canvas` in `:core:design`, not Vico. No chart dependency is added; ~~Vico stays in `android/gradle/libs.versions.toml` unused and can be removed in a later cleanup.~~ (Vico removed from the catalog on 2026-10-18, `a312758`; SPEC v0.14.) Takes the fallback §6.2 already allowed. As landed by M6 (`7730a31`). | M6 report: Vico 3.3.1 cannot give design-spec §3.10's one focusable TalkBack node per bar with its own sentence and tap target, 2 dp gaps between segments, the 10/4 corner radii on the whole bar (not per segment; 4/2 since D-097) and a y-axis gutter that grows at 200 % font scale. | SPEC §6.2 (Charts, module table), §11.2, §11.3 |
|
||||
| D-064 | 2026-10-18 | Material 3 Expressive against material3 1.4.0: `ShortNavigationBar` is public and used. Not usable, with the fallback taken: `MaterialShapes` (Cookie4, Clover4Leaf, Sunny, Cookie9) → rounded 30 %, rounded 16 dp, circle, circle; `ButtonGroup` → `SingleChoiceSegmentedButtonRow` styled per the design spec; `LoadingIndicator` → `CircularProgressIndicator`; `LinearWavyProgressIndicator` → `LinearProgressIndicator`; medium extended FAB → `ExtendedFloatingActionButton` 56 dp high, radius 20; Expressive button sizes → `Button` with `heightIn`; segmented list items → Kvitt's own `SegmentedGroup`; Expressive slider → Kvitt's own split bar with a knob; `MotionScheme.expressive()` and the `MaterialTheme(motionScheme)` overload are internal → Material components keep the default motion and Kvitt components use the design-spec §2.11 springs in `KvittMotion`; `Shapes.largeIncreased` and the other increased shapes are internal → held in `KvittShapes`. Fulfils design-spec §6 open point 5. | M6 report: the design spec names Expressive APIs that material3 1.4.0 (pinned since M2b) does not expose publicly; the spec's rule is a fallback without a design change. | SPEC §6.2 (UI); `docs/ux/design-spec.md` §3.1, §6 |
|
||||
| D-065 | 2026-10-18 | Formatting tables are code: Swedish and English month and weekday names are tables inside `DateFormatter`, not platform locale data, so JVM tests and phone output are identical. Chart axis labels have no dots ("maj, jun, … okt"); list dates keep the Swedish dot ("3 okt.", "25 sep."). File sizes are SI megabytes (10^6 bytes) with one decimal, rounded half up, U+00A0 before "MB" ("8,4 MB", "8.4 MB"). | M6 report: platform locale data differs between the JVM used by tests and Android, and §9.8 gave no file-size format for the update banner. | SPEC §9.8 |
|
||||
| D-066 | 2026-10-18 | Golden layout: ~~one Roborazzi golden per component sheet × light/dark × sv/en (`<sheet>_<lang>_<theme>.png`), plus a 200 % font-scale variant for long states;~~ (revised by D-098: three variants `sv_light`, `sv_dark`, `en_light` plus `sv_light_200`, at 1x density) the content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Feature modules (M7a to M7f) follow the same layout. | M6 report: one layout keeps the goldens reviewable and the feature agents' screenshot tests consistent. | SPEC §6.2 (Screenshot tests) |
|
||||
| D-067 | 2026-10-18 | The English default name of the category `restaurants` is "Dining" (was "Restaurants"); Swedish "Restaurang & café" stays. Changed in AC-7.2, `docs/ux/strings.csv` and `android/core/design/src/main/res/values/strings.xml` (`category_default_restaurants`) together. | Owner approved: "Restaurants" clips at 11 sp in the six-column `CategoryQuickRow`, and design-spec §3.7 allows no ellipsis there. | SPEC AC-7.2, §13.4; `docs/ux/strings.csv` |
|
||||
| D-068 | 2026-10-18 | New shared string `ledger_settlement_label` ("Payment" / "Betalning", `module=core:design`) as the type label on the second line of a settlement row (design-spec §3.5), added to `docs/ux/strings.csv` beside the other `ledger_settlement_*` rows and to `:core:design`'s `values/strings.xml` and `values-sv/strings.xml`. Also: `SettlementRow` titles may wrap to 2 lines (3 at 200 % font scale), and a chart whose range has no expenses shows only the 0 baseline with no gridline values (design-spec §3.10). | M6 report: `settle_detail_title` belongs to `:feature:settle` and `:feature:account` cannot use it (D-017); the wrap and the empty-chart rendering were not stated in the design spec. | SPEC §13.3 (S-5, S-11), §13.4; `docs/ux/strings.csv` |
|
||||
| D-069 | 2026-10-18 | S-11 readings. Total-card caption "<range> · Hushållet" for the household measure and "<range> · N veckor" in week granularity; for my share the label is "Totalt · Min del", so the caption does not repeat it. Month presets show whole-month ranges ("maj–okt 2026"), custom ranges and week granularity show days. Legend and stacking order: categories by `sortOrder`, accounts in `AccountRepository.all()` order; the order of `AnalyticsState.groups` is unspecified and the UI sorts. "Loading" means `SyncSummary.lastSuccessAt == null` and shows skeleton cards; the design spec has no offline or loading frame for S-11/S-12, so offline looks like loaded. | M7e report (`6ac711d`): the design spec left caption wording, order and the loading condition open. | SPEC §13.3 (S-11), §14.2.4 ("Readings fixed in v0.11") |
|
||||
| D-070 | 2026-10-18 | S-12 readings. A week title follows `docs/ux/strings.csv` ("Vardag, vecka 41"), not "v. 41"; a category or account over the whole range is titled "<name>, <range caption>". No error state on S-11 or S-12 (analytics are local, AC-14.1); S-12 has no loading state. | M7e report (`6ac711d`): design spec and strings disagreed on the week title; the error and loading states of §13.3 do not apply to local analytics. | SPEC §13.3 (S-11, S-12) |
|
||||
| D-069 | 2026-10-18 | S-11 readings. Total-card caption "<range> · Hushållet" for the household measure and "<range> · N veckor" in week granularity; for my share the label is "Totalt · Min del", so the caption does not repeat it. Month presets show whole-month ranges ("maj–okt 2026"), custom ranges and week granularity show days. Legend and stacking order: categories by `sortOrder`, accounts in `AccountRepository.all()` order; the order of `AnalyticsState.groups` is unspecified and the UI sorts. "Loading" means `SyncSummary.lastSuccessAt == null` and shows skeleton cards; the design spec has no offline or loading frame for S-11/S-12, so offline looks like loaded. | M7e report (`ea7d494`): the design spec left caption wording, order and the loading condition open. | SPEC §13.3 (S-11), §14.2.4 ("Readings fixed in v0.11") |
|
||||
| D-070 | 2026-10-18 | S-12 readings. A week title follows `docs/ux/strings.csv` ("Vardag, vecka 41"), not "v. 41"; a category or account over the whole range is titled "<name>, <range caption>". No error state on S-11 or S-12 (analytics are local, AC-14.1); S-12 has no loading state. | M7e report (`ea7d494`): design spec and strings disagreed on the week title; the error and loading states of §13.3 do not apply to local analytics. | SPEC §13.3 (S-11, S-12) |
|
||||
| D-071 | 2026-10-18 | Navigation wiring. The top-level screens S-3, S-4, S-11 and S-13 render their own `KvittNavigationBar` and take `onNavigate: (KvittDestination) -> Unit`; the `:app` scaffold draws no second bar; the `NavHost` switches top-level destinations from those lambdas. Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID`/`ARG_ACCOUNT_ID`, `SettleArgs.SETTLEMENT_ID`/`ACCOUNT_ID`, `DrillDownArgs.KEYS`, settings `"categoryId"`/`"templateId"`), read through `SavedStateHandle`. Result snackbars of S-7, S-9 and account archiving are shown by the screen returned to ~~(Home through its `snackbarHostState`)~~ (revised by D-095: six screens host the shared `snackbarHostState` in their own scaffold, `:app` has no overlay host); the account-archived snackbar has an undo action (`common_undo`). | M7a, M7b, M7e, M7f reports and M8's brief: the features built the bar and the argument keys themselves; M8 needs one rule to wire them. | SPEC §13.2, §11.2 (M8), §11.3 (M8) |
|
||||
| D-072 | 2026-10-18 | A new recurring template's "Dag i månaden" defaults to today's day of the month; the start month is the current month if that day is today or later, else the next month (so by default the current month). | M7f report (`a54eb7c`): AC-9.8 gave the start-month rule but no default day. | SPEC AC-9.8, §13.3 (S-19) |
|
||||
| D-073 | 2026-10-18 | ~~"Sök efter uppdatering" (S-22) reports "Du har den senaste versionen" when `UpdateState` is still `None` after `check()`; a network failure cannot be told apart because `check()` returns nothing. Accepted for v1; "give `UpdateRepository.check()` a result" is a contract request for a later contract pass.~~ (Revised by D-092: `check()` returns `UpdateCheck`, and S-22 tells up to date from a failure.) | M7f report (`a54eb7c`): the UI contract has no result for a manual check. | SPEC AC-16.6 (new), §13.3 (S-22), §14.2.4, §11.3 |
|
||||
| D-074 | 2026-10-18 | `FieldError.field` values are the model property names `amountOre`, `description`, `notes`, `date`, `split`, `accountId`, `categoryId`, `allocations`, as `:core:fakes` and `:data` emit them. Editing a settlement opens with its saved rows counted as user-edited, so changing the amount keeps them; "Föreslå igen" re-proposes. ~~The S-9 info line on untouched accounts derives them from Home balances and may be slightly off while editing (balances include the settlement being edited).~~ (Revised by D-093: S-9 reads `SettleState.untouched`, nets without the settlement being edited.) | M7c (`1e42f76`) and M7d (`082b8dc`) reports: field names were not fixed; AC-11.6 did not say whether an edit re-proposes; `SettleState` has no untouched-accounts list. | SPEC §14.2.4, AC-11.6, §13.3 (S-9), §11.3 |
|
||||
| D-075 | 2026-10-18 | The quick-row fill of D-060 lives in `:core:design`'s `quickRowCategories(categories, frequent, selected)`; editors pass the raw `frequentCategoryIds[accountId]`; an empty or short list is filled from `sort_order`, and the selected category takes fifth place when absent. Replaces `docs/ux/design-spec.md` §3.7's interim "selected first, then sort_order". | M6b (`3a1bd14`): S-7 and S-19 both need the fill; one helper keeps them identical. | SPEC §14.2.4, §13.3 (S-7), §11.2 (M6b) |
|
||||
| D-076 | 2026-10-18 | Category item labels (quick row and grid) take up to two centred lines; longer custom names end the second line in "…"; single long words still hyphenate ("Entertain-ment"); TalkBack reads the full name. "No ellipsis" (design-spec §3.7) still holds for the default names. | M6b (`3a1bd14`): custom category names (up to 40 code points, §9.6) can be longer than two lines; the default names still fit without an ellipsis. | SPEC §13.3 (S-7); `docs/ux/design-spec.md` §3.7 |
|
||||
| D-077 | 2026-10-18 | Day ranges in captions and summaries are written out with the year ("24 augusti–17 oktober 2026", "1–30 november 2026", "28 december 2025–3 januari 2026"); the period chip and the S-12 title keep the short form "24 aug.–17 okt.". Extends D-065. | M6b (`3a1bd14`, `DateFormatter.dayRangeLong`): M7e needed a long form for the total caption. | SPEC §9.8, §13.3 (S-11) |
|
||||
| D-078 | 2026-10-18 | A stored amount opens for editing with both decimals ("1 247,60", "12 400,00"); ratio percent fields keep the trimmed form ("60", "60,5"). Note: `viewerPercentOf` now reads basis points (bug fix, no rule change). | M6b (`3a1bd14`, `AmountInput.of` and `AmountInput.percent`, replacing M7c's own `moneyInput`): money is always shown with two decimals (§9.8), percents are not money. | SPEC §9.6 |
|
||||
| D-079 | 2026-10-18 | New shared keys `common_ok`, `common_undo`, `editor_split_full_on_you` ("All on you" / "Allt på dig"; `ledger_settlement_label` was D-068). The module keys proposed by M7b to M7f are recorded in `docs/ux/strings.csv` as built: `analytics_granularity`, `analytics_measure`, `analytics_group_by`, `analytics_range_apply`; `settle_row_amount`, `settle_detail_delete`, `settle_detail_sync_now`; `expense_detail_sync_now`; `home_archived_expanded`, `home_archived_collapsed`; `settings_category_archived_snackbar`, `settings_category_unarchived_snackbar`, `settings_recurring_summary_none`, `settings_expanded`, `settings_collapsed`, `settings_partner_missing`. `strings.csv` has 578 rows, header included. | M7b to M7f reports and M6b (`3a1bd14`): §13.4 requires a decisions entry for strings not in the file. | SPEC §13.4; `docs/ux/strings.csv` |
|
||||
| D-080 | 2026-10-18 | "Synka nu" (the missing states of S-8 and S-10, and S-16) is an online-only action, disabled with "Kräver anslutning" offline. Discard dialogs (S-6, S-7, S-9, S-19) have a title and no body text. | M7c report (`1e42f76`): §9.9 did not list "Sync now", which needs the server; the discard dialogs' body text was not fixed. | SPEC §9.9, §13.3 (S-6, S-7, S-8, S-9, S-10, S-16, S-19) |
|
||||
| D-081 | 2026-10-18 | Both variants bind the `:core:model` interfaces to `:data` through `android/app/src/main/.../di/AppDataModule.kt`, which includes `DataBindingsModule` (D-041). `DebugFakesModule` (`72f21b2`) and the release-only data module are deleted; no Hilt module lives in `src/debug` or `src/release`. Fakes live only in test source sets and feature previews; `:app`'s unit tests run on a plain `Application`. Revises D-041 ("debug binds the fakes"). (Proposed by M8 as D-085, renumbered.) | M8 report (`369e516`): the debug app must pair against the development server (D-001), so debug needs the real `:data` graph. | SPEC §6.2, §11.2 (M8), §11.3 (M8) |
|
||||
| D-082 | 2026-10-18 | A network security config cannot express IP ranges, so the debug config (`android/app/src/debug/res/xml/network_security_config.xml`) allows cleartext to every host and the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses only) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. The release config is in `src/main` (cleartext off, system anchors); the pinned-root config of §10.5.3 is in `src/releasePinnedRoot/res`, added to the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended. (M8's D-086, renumbered.) | M8 report (`369e516`): §8.5 asked for cleartext "to the hosts listed in AC-2.8", which the config format cannot list. | SPEC §8.5, §10.5.3, AC-2.8 |
|
||||
| D-083 | 2026-10-18 | FCM registration by Firebase Installation ID (firebase-messaging 26.0.0 through the Firebase BoM 35.0.2, google-services plugin 4.5.0). `register()` runs whenever the phone is paired and at each app start while paired; `onRegistered(fid)` sends the id as `fcm_token` with E-17; when the device is revoked the app calls `unregister()`. Needs the manifest meta-data `firebase_messaging_installation_id_enabled`. ~~No server change: FCM HTTP v1 `message.token` accepts installation ids during Firebase's transition. Follow-up request for the server (§11.3 M12e list): send the id in the `fid` field.~~ (Revised by D-096: the server sends the id in `message.fid`, `c694f78`.) (M8's D-087, renumbered.) | M8 report (`369e516`): firebase-messaging 26 registers an app instance by its Firebase Installation ID; AC-13.7 described a rotating FCM token. | SPEC AC-13.7, §6.2, §14.6, §11.3 |
|
||||
| D-084 | 2026-10-18 | An invite link (`kvitt://invite` or `https://icehack.asuscomm.com:8443/i/<CODE>`, declared with `autoVerify="false"`) opened while the phone is paired is ignored; the app never re-pairs over live data. (M8's D-088, renumbered.) | M8 report (`369e516`): AC-2.6 did not say what a link does on a paired phone; re-pairing would discard the local copy and its pending changes. | SPEC AC-2.6 |
|
||||
| D-085 | 2026-10-18 | Push handling: an expedited WorkManager job calls `syncForPush` (waited up to 20 s), then builds the notification text from Room. A tap on the generic notification (sync failed, or entity or actor missing locally) opens Home, not the detail; deletions and `sync_batch` also open Home; an unknown `type` or `v` ≠ `1` only syncs. Channel `activity`; notification id = hash of `entity_id`, `sync_batch` uses id `1`. (M8's D-089, renumbered.) | M8 report (`369e516`): AC-13.6 and §14.6 did not say where a generic notification leads or which fixed id `sync_batch` uses. | SPEC AC-13.6, §14.6 |
|
||||
| D-072 | 2026-10-18 | A new recurring template's "Dag i månaden" defaults to today's day of the month; the start month is the current month if that day is today or later, else the next month (so by default the current month). | M7f report (`c4ab35f`): AC-9.8 gave the start-month rule but no default day. | SPEC AC-9.8, §13.3 (S-19) |
|
||||
| D-073 | 2026-10-18 | ~~"Sök efter uppdatering" (S-22) reports "Du har den senaste versionen" when `UpdateState` is still `None` after `check()`; a network failure cannot be told apart because `check()` returns nothing. Accepted for v1; "give `UpdateRepository.check()` a result" is a contract request for a later contract pass.~~ (Revised by D-092: `check()` returns `UpdateCheck`, and S-22 tells up to date from a failure.) | M7f report (`c4ab35f`): the UI contract has no result for a manual check. | SPEC AC-16.6 (new), §13.3 (S-22), §14.2.4, §11.3 |
|
||||
| D-074 | 2026-10-18 | `FieldError.field` values are the model property names `amountOre`, `description`, `notes`, `date`, `split`, `accountId`, `categoryId`, `allocations`, as `:core:fakes` and `:data` emit them. Editing a settlement opens with its saved rows counted as user-edited, so changing the amount keeps them; "Föreslå igen" re-proposes. ~~The S-9 info line on untouched accounts derives them from Home balances and may be slightly off while editing (balances include the settlement being edited).~~ (Revised by D-093: S-9 reads `SettleState.untouched`, nets without the settlement being edited.) | M7c (`0b140aa`) and M7d (`7380e50`) reports: field names were not fixed; AC-11.6 did not say whether an edit re-proposes; `SettleState` has no untouched-accounts list. | SPEC §14.2.4, AC-11.6, §13.3 (S-9), §11.3 |
|
||||
| D-075 | 2026-10-18 | The quick-row fill of D-060 lives in `:core:design`'s `quickRowCategories(categories, frequent, selected)`; editors pass the raw `frequentCategoryIds[accountId]`; an empty or short list is filled from `sort_order`, and the selected category takes fifth place when absent. Replaces `docs/ux/design-spec.md` §3.7's interim "selected first, then sort_order". | M6b (`b24c8b5`): S-7 and S-19 both need the fill; one helper keeps them identical. | SPEC §14.2.4, §13.3 (S-7), §11.2 (M6b) |
|
||||
| D-076 | 2026-10-18 | Category item labels (quick row and grid) take up to two centred lines; longer custom names end the second line in "…"; single long words still hyphenate ("Entertain-ment"); TalkBack reads the full name. "No ellipsis" (design-spec §3.7) still holds for the default names. | M6b (`b24c8b5`): custom category names (up to 40 code points, §9.6) can be longer than two lines; the default names still fit without an ellipsis. | SPEC §13.3 (S-7); `docs/ux/design-spec.md` §3.7 |
|
||||
| D-077 | 2026-10-18 | Day ranges in captions and summaries are written out with the year ("24 augusti–17 oktober 2026", "1–30 november 2026", "28 december 2025–3 januari 2026"); the period chip and the S-12 title keep the short form "24 aug.–17 okt.". Extends D-065. | M6b (`b24c8b5`, `DateFormatter.dayRangeLong`): M7e needed a long form for the total caption. | SPEC §9.8, §13.3 (S-11) |
|
||||
| D-078 | 2026-10-18 | A stored amount opens for editing with both decimals ("1 247,60", "12 400,00"); ratio percent fields keep the trimmed form ("60", "60,5"). Note: `viewerPercentOf` now reads basis points (bug fix, no rule change). | M6b (`b24c8b5`, `AmountInput.of` and `AmountInput.percent`, replacing M7c's own `moneyInput`): money is always shown with two decimals (§9.8), percents are not money. | SPEC §9.6 |
|
||||
| D-079 | 2026-10-18 | New shared keys `common_ok`, `common_undo`, `editor_split_full_on_you` ("All on you" / "Allt på dig"; `ledger_settlement_label` was D-068). The module keys proposed by M7b to M7f are recorded in `docs/ux/strings.csv` as built: `analytics_granularity`, `analytics_measure`, `analytics_group_by`, `analytics_range_apply`; `settle_row_amount`, `settle_detail_delete`, `settle_detail_sync_now`; `expense_detail_sync_now`; `home_archived_expanded`, `home_archived_collapsed`; `settings_category_archived_snackbar`, `settings_category_unarchived_snackbar`, `settings_recurring_summary_none`, `settings_expanded`, `settings_collapsed`, `settings_partner_missing`. `strings.csv` has 578 rows, header included. | M7b to M7f reports and M6b (`b24c8b5`): §13.4 requires a decisions entry for strings not in the file. | SPEC §13.4; `docs/ux/strings.csv` |
|
||||
| D-080 | 2026-10-18 | "Synka nu" (the missing states of S-8 and S-10, and S-16) is an online-only action, disabled with "Kräver anslutning" offline. Discard dialogs (S-6, S-7, S-9, S-19) have a title and no body text. | M7c report (`0b140aa`): §9.9 did not list "Sync now", which needs the server; the discard dialogs' body text was not fixed. | SPEC §9.9, §13.3 (S-6, S-7, S-8, S-9, S-10, S-16, S-19) |
|
||||
| D-081 | 2026-10-18 | Both variants bind the `:core:model` interfaces to `:data` through `android/app/src/main/.../di/AppDataModule.kt`, which includes `DataBindingsModule` (D-041). `DebugFakesModule` (`a41f240`) and the release-only data module are deleted; no Hilt module lives in `src/debug` or `src/release`. Fakes live only in test source sets and feature previews; `:app`'s unit tests run on a plain `Application`. Revises D-041 ("debug binds the fakes"). (Proposed by M8 as D-085, renumbered.) | M8 report (`cb832a7`): the debug app must pair against the development server (D-001), so debug needs the real `:data` graph. | SPEC §6.2, §11.2 (M8), §11.3 (M8) |
|
||||
| D-082 | 2026-10-18 | A network security config cannot express IP ranges, so the debug config (`android/app/src/debug/res/xml/network_security_config.xml`) allows cleartext to every host and the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses only) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. The release config is in `src/main` (cleartext off, system anchors); the pinned-root config of §10.5.3 is in `src/releasePinnedRoot/res`, added to the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended. (M8's D-086, renumbered.) | M8 report (`cb832a7`): §8.5 asked for cleartext "to the hosts listed in AC-2.8", which the config format cannot list. | SPEC §8.5, §10.5.3, AC-2.8 |
|
||||
| D-083 | 2026-10-18 | FCM registration by Firebase Installation ID (firebase-messaging 26.0.0 through the Firebase BoM 35.0.2, google-services plugin 4.5.0). `register()` runs whenever the phone is paired and at each app start while paired; `onRegistered(fid)` sends the id as `fcm_token` with E-17; when the device is revoked the app calls `unregister()`. Needs the manifest meta-data `firebase_messaging_installation_id_enabled`. ~~No server change: FCM HTTP v1 `message.token` accepts installation ids during Firebase's transition. Follow-up request for the server (§11.3 M12e list): send the id in the `fid` field.~~ (Revised by D-096: the server sends the id in `message.fid`, `f56dcbe`.) (M8's D-087, renumbered.) | M8 report (`cb832a7`): firebase-messaging 26 registers an app instance by its Firebase Installation ID; AC-13.7 described a rotating FCM token. | SPEC AC-13.7, §6.2, §14.6, §11.3 |
|
||||
| D-084 | 2026-10-18 | An invite link (`kvitt://invite` or `https://icehack.asuscomm.com:8443/i/<CODE>`, declared with `autoVerify="false"`) opened while the phone is paired is ignored; the app never re-pairs over live data. (M8's D-088, renumbered.) | M8 report (`cb832a7`): AC-2.6 did not say what a link does on a paired phone; re-pairing would discard the local copy and its pending changes. | SPEC AC-2.6 |
|
||||
| D-085 | 2026-10-18 | Push handling: an expedited WorkManager job calls `syncForPush` (waited up to 20 s), then builds the notification text from Room. A tap on the generic notification (sync failed, or entity or actor missing locally) opens Home, not the detail; deletions and `sync_batch` also open Home; an unknown `type` or `v` ≠ `1` only syncs. Channel `activity`; notification id = hash of `entity_id`, `sync_batch` uses id `1`. (M8's D-089, renumbered.) | M8 report (`cb832a7`): AC-13.6 and §14.6 did not say where a generic notification leads or which fixed id `sync_batch` uses. | SPEC AC-13.6, §14.6 |
|
||||
| D-086 | 2026-10-18 | E-3's response gains `invite_kind` (the accepted invite's kind). `SessionRepository.pair` returns `OnlineResult<Pairing(user, inviteKind)>`; a wire value the app does not know is read as `device`. S-2 follows only a `partner` pairing; a member pairing a second phone with a `device` invite goes to Home. Fakes: `FakeScenario.pairingInviteKind`. | M7a and M8 reports (§11.3 list): without the kind, S-2 showed after any first `member` pairing, device invites included. | SPEC AC-2.4, AC-2.10, §13.2, §13.3 (S-2), §14.2.4, §14.3.4 (E-3); `contracts/examples/E-3-invites-accept` |
|
||||
| D-087 | 2026-10-18 | E-12's link is `<public_url>/i/<code>?from=<percent-encoded admin display name>`. E-4 takes the optional `from` and passes it only into `kvitt://invite?server&code&from`; it is never shown on the page (AC-2.5 still holds). An invalid value (not UTF-8, a control character, more than 40 code points) is dropped and the page still opens. `InviteLink` (`PastedInvite.adminName`), `AppLink.Invite`, `PairingScreen(initialAdminName)` and `PairingViewModel.prefill` carry the name; S-1 shows `onboarding_code_help` with it. Older links without a name still parse. | M7a report (§11.3 list): `onboarding_code_help` could not be filled, so S-1 showed only the generic line. | SPEC AC-2.5, AC-2.6, §13.3 (S-1), §14.3.4 (E-4, E-12); `contracts/examples/E-12-create-invite`, `contracts/openapi.yaml` |
|
||||
| D-088 | 2026-10-18 | `SyncSummary.lastAttemptFailed`: true when the last finished cycle started after the last success (or none ever succeeded) and no cycle is running. `SyncStatusPill`, `syncPillVariant` and `syncLineText` drop their `failed` parameter; S-16 derives "failed" as online plus `lastAttemptFailed`. | M6 and M7b reports (§11.3 list): Home passed `failed = false`, so "Sync failed" never showed. | SPEC §13.3 (S-4, S-16), §14.2.4, §11.3 (M6) |
|
||||
@@ -100,16 +100,25 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| D-093 | 2026-10-18 | `SettleState.untouched: List<UntouchedAccount(account, payerNetOre)>`: accounts not in the rows where the payer is owed, with nets that exclude the settlement being edited, in `AccountRepository.all()` order; empty for a per-account settle-up. Computed in `:data` and in the fakes. S-9 uses it and no longer reads Home nets. Revises D-074's S-9 reading. | M7d report (D-074, §11.3 list): Home balances include the settlement being edited. | SPEC §13.3 (S-9), §14.2.4 |
|
||||
| D-094 | 2026-10-18 | The D-027 re-stamp as implemented: when `sync_state.clock_skew` is set at the start of a cycle, every pending expense and settlement whose `updated_at` (or a tombstone's `deleted_at`) is later than the phone's clock is re-stamped to now, in one Room transaction before pushing; rows stamped no later than now are untouched. Room schema unchanged. Revises D-027. | M5 report (D-027, §11.3 list): the re-stamp was not in `:data`. | SPEC §14.4.5 step 3 |
|
||||
| D-095 | 2026-10-18 | `PushEvent` lives in `:core:model` (`model/Push.kt`); `:adapter:fcm` keeps `PushListener` and `PushMapping`. Six screens host the shared `snackbarHostState` in their own scaffold (Home, AccountDetail, ExpenseDetail, SettlementDetail, DrillDown, SyncStatus); `:app` has no overlay host. `ExpenseEditorScreen.onSaved(ExpenseId, @StringRes Int?)`, `onDeleted()` without argument on S-7 and S-8; `:app` shows `snackbar_expense_deleted`. Launcher icon: adaptive, vectors only, `mipmap-anydpi/ic_launcher.xml`, background `#9C5238` (light primary), foreground and monochrome `receipt_long` mark at 48 dp, manifest `icon` and `roundIcon`; lint `MissingApplicationIcon` cleared. Revises D-071. | M8 report (§11.3 list): `PushEvent` was in the adapter, the overlay host could cover a FAB or the bottom bar, S-7 and S-8 handed over resolved strings, the app had no launcher icon. | SPEC §6.2, §13.2, §13.3 (S-7, S-8), §14.2.4, §14.6, §11.3 |
|
||||
| D-096 | 2026-10-18 | The server sends the device's stored `fcm_token` (the Firebase Installation ID of D-083) in the FCM HTTP v1 `message.fid` field, no longer in the deprecated `message.token`. No database or wire change: the `devices.fcm_token` column and the E-17 field keep the name `fcm_token`. `contracts/examples/push/*.json` use `fid` (a contract-file change, hence this entry and SPEC v0.14). Landed `c694f78`. Revises D-083's "no server change". | The FCM HTTP v1 reference (projects.messages, last updated 2026-09-08, read 2026-10-18) deprecates `token` with "Use fid instead" and defines `fid` as the Firebase Installation ID target; `token`, `fid`, `topic` and `condition` are mutually exclusive. Live probe from the arm64 image (`docs/spikes/pi-image-arm64.md`): FCM answered "unregistered" for a made-up id, so the field is accepted. | SPEC §14.6, AC-13.7, §11.3; `server/crates/kvitt-server/src/push/fcm.rs`, `contracts/examples/push/` |
|
||||
| D-097 | 2026-10-18 | S-11 chart bars have top corners 4 dp and bottom corners 2 dp on the whole bar, replacing top 10, bottom 4. The 2 dp gaps between segments and the clip of the whole bar (not per segment) are unchanged. Assumption recorded: the owner's "4 outer / 2 inner" means top / bottom. Landed by M8b (`f0575d3`) in `KvittShapes` and `Charts.kt`, goldens re-recorded. | First phone test: the owner found the bar tops too round. | SPEC §6.2 (Charts), §13.4; `docs/ux/design-spec.md` §2.9 (shapes table), §3.10 |
|
||||
| D-098 | 2026-10-18 | Golden layout: three variants per component sheet or screen state, `<name>_sv_light`, `<name>_sv_dark`, `<name>_en_light` (English covers string length, dark covers the theme; no `en_dark`), plus `<name>_sv_light_200` for the long states; rendered at 1x density through the Robolectric qualifier `w411dp-h<height>dp-mdpi`, so a full screen is 411 × 891 px. Every committed PNG, the `docs/ux/` mock-ups included, is compressed losslessly with `oxipng -o 4 --strip safe` (`7c453d8`). Landed by M8c (`b481fab`). Revises D-066 (the compose-once configuration context stays). | The owner wants a small repository for the home Gitea server: the goldens went from 1143 PNGs / 131 MB to 865 / 26 MB, the mock-ups from 36.8 to 26.0 MB. | SPEC §6.2 (Screenshot tests), §13.4; `android/README.md` |
|
||||
| D-099 | 2026-10-18 | S-22 About has no open-source licences row: `AboutScreen`'s `onOpenLicences` (and `AboutActions.onOpenLicences`, the `:app` call site) and the string `settings_about_licences` are removed, also from `docs/ux/strings.csv` (577 rows). Landed by M8c (`64f5706`). Closes OQ-5. | Owner answer to OQ-5 on 2026-10-18: no licences screen in v1, hide the button. | SPEC §11.3, §12 (OQ-5), §13.3 (S-22), §13.4; `docs/ux/design-spec.md` S-22 |
|
||||
| D-100 | 2026-10-18 | No SSH from the Mac to the Pis. M2c-pi produces a self-contained bundle `infra/pi/` (compose file, Caddyfile, env template, install, deploy, bootstrap and backup scripts, a README checklist) that the owner copies to the Kvitt Pi and runs there. The OQ-3 values (Kvitt Pi address, Home Assistant Pi address, SSH port and user, certificate directory, SSH add-on) become placeholders the owner fills in the bundle's `.env`; OQ-3 is answered by this. Where the spec has a Mac script act on the Pi (`infra/deploy.sh`, `infra/publish-apk.sh`), the step becomes a bundle script the owner runs; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. Agents never need allowances for the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. Revises D-023 and the M2c-pi plan; D-001 stands. Fulfilled by D-103 to D-106 (bundle landed `5e027df`). | Owner decision 2026-10-18: no agent access to the home servers; the Pi is set up once, by hand, from a checklist. | SPEC §10 intro, §10.1, §10.10, §11.1, §11.2 (M2c-pi, M10), §11.3, §12 (OQ-3); PLAN-M2c-pi |
|
||||
| D-096 | 2026-10-18 | The server sends the device's stored `fcm_token` (the Firebase Installation ID of D-083) in the FCM HTTP v1 `message.fid` field, no longer in the deprecated `message.token`. No database or wire change: the `devices.fcm_token` column and the E-17 field keep the name `fcm_token`. `contracts/examples/push/*.json` use `fid` (a contract-file change, hence this entry and SPEC v0.14). Landed `f56dcbe`. Revises D-083's "no server change". | The FCM HTTP v1 reference (projects.messages, last updated 2026-09-08, read 2026-10-18) deprecates `token` with "Use fid instead" and defines `fid` as the Firebase Installation ID target; `token`, `fid`, `topic` and `condition` are mutually exclusive. Live probe from the arm64 image (`docs/spikes/pi-image-arm64.md`): FCM answered "unregistered" for a made-up id, so the field is accepted. | SPEC §14.6, AC-13.7, §11.3; `server/crates/kvitt-server/src/push/fcm.rs`, `contracts/examples/push/` |
|
||||
| D-097 | 2026-10-18 | S-11 chart bars have top corners 4 dp and bottom corners 2 dp on the whole bar, replacing top 10, bottom 4. The 2 dp gaps between segments and the clip of the whole bar (not per segment) are unchanged. Assumption recorded: the owner's "4 outer / 2 inner" means top / bottom. Landed by M8b (`328c466`) in `KvittShapes` and `Charts.kt`, goldens re-recorded. | First phone test: the owner found the bar tops too round. | SPEC §6.2 (Charts), §13.4; `docs/ux/design-spec.md` §2.9 (shapes table), §3.10 |
|
||||
| D-098 | 2026-10-18 | Golden layout: three variants per component sheet or screen state, `<name>_sv_light`, `<name>_sv_dark`, `<name>_en_light` (English covers string length, dark covers the theme; no `en_dark`), plus `<name>_sv_light_200` for the long states; rendered at 1x density through the Robolectric qualifier `w411dp-h<height>dp-mdpi`, so a full screen is 411 × 891 px. Every committed PNG, the `docs/ux/` mock-ups included, is compressed losslessly with `oxipng -o 4 --strip safe` (`7c453d8`). Landed by M8c (`75b549f`). Revises D-066 (the compose-once configuration context stays). | The owner wants a small repository for the home Gitea server: the goldens went from 1143 PNGs / 131 MB to 865 / 26 MB, the mock-ups from 36.8 to 26.0 MB. | SPEC §6.2 (Screenshot tests), §13.4; `android/README.md` |
|
||||
| D-099 | 2026-10-18 | S-22 About has no open-source licences row: `AboutScreen`'s `onOpenLicences` (and `AboutActions.onOpenLicences`, the `:app` call site) and the string `settings_about_licences` are removed, also from `docs/ux/strings.csv` (577 rows). Landed by M8c (`a0c25b0`). Closes OQ-5. | Owner answer to OQ-5 on 2026-10-18: no licences screen in v1, hide the button. | SPEC §11.3, §12 (OQ-5), §13.3 (S-22), §13.4; `docs/ux/design-spec.md` S-22 |
|
||||
| D-100 | 2026-10-18 | No SSH from the Mac to the Pis. M2c-pi produces a self-contained bundle `infra/pi/` (compose file, Caddyfile, env template, install, deploy, bootstrap and backup scripts, a README checklist) that the owner copies to the Kvitt Pi and runs there. The OQ-3 values (Kvitt Pi address, Home Assistant Pi address, SSH port and user, certificate directory, SSH add-on) become placeholders the owner fills in the bundle's `.env`; OQ-3 is answered by this. Where the spec has a Mac script act on the Pi (`infra/deploy.sh`, `infra/publish-apk.sh`), the step becomes a bundle script the owner runs; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. Agents never need allowances for the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. Revises D-023 and the M2c-pi plan; D-001 stands. Fulfilled by D-103 to D-106 (bundle landed `2476667`). | Owner decision 2026-10-18: no agent access to the home servers; the Pi is set up once, by hand, from a checklist. | SPEC §10 intro, §10.1, §10.10, §11.1, §11.2 (M2c-pi, M10), §11.3, §12 (OQ-3); PLAN-M2c-pi |
|
||||
| D-101 | 2026-10-18 | The release keystore `kvitt-release.jks` (created with the D-024 `keytool` command) lives at the repository root of the main checkout, git-ignored by the `*.jks` pattern of `.gitignore` and never committed; `android/local.properties` references it by absolute path in `kvitt.release.storeFile` with the other three `kvitt.release.*` keys. The owner keeps a copy of the file and its password in their password manager. Revises D-024 ("outside the repository"). | Owner decision 2026-10-18; the `*.jks` ignore rule already keeps the file out of git. | SPEC §10.6, §11.2 (M10), §11.3; PLAN-M10 |
|
||||
| D-102 | 2026-10-18 | S-7 editor options as fixed rows. Below the category quick row, account, date, payer and split are four full-width rows in a fixed order (Konto, Datum, Betalt av, Delning) instead of a wrapping chip row, so no option moves when a value changes. Rows form a segmented group (outer corners 24, inner 8, gap 3) in surface-container-high (secondary-container while the split section is open), each at least 48 dp and tappable in full. A row has a leading element in a 24 dp box, a label (label-large, muted) in a column as wide as the widest label, the value (body-large, one line, ellipsis) and the trailing affordance at the right edge (expand_more/expand_less, swap_horiz for the payer). At ≥ 1.5× font scale the label sits over the value, which takes up to two lines, and every row keeps the two-line height. TalkBack sentences are unchanged. S-9 and S-19 keep their chips (S-19 follows in M8e, D-107). Also: connected buttons get 8 dp side padding so 'Household' fits half of S-11's filter bar at 1x. Landed by M8d (`3c68594`): `EditorOptionRows` in `:core:design`, `KvittDimens.connectedButtonSide`. | Owner finding on S-7, 2026-10-18: the chips moved when a value changed. | SPEC §13.3 (S-7), §13.4; `docs/ux/design-spec.md` §3.7, §3.10 |
|
||||
| D-103 | 2026-10-18 | The M2c-pi bundle `infra/pi/`: `compose.yaml` (was `infra/compose.yaml`), `.env.example` (was `infra/env.example`), `Caddyfile`, and the on-Pi scripts `install.sh` (idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secrets with their modes, adds `gitea.lan` to `insecure-registries`, sets the volume owners, starts the stack), `deploy.sh <tag> [--skip-backup]` (backs up before every deploy, because the Pi cannot tell whether a tag carries a migration; keeps `/opt/kvitt/previous-tag` for rollback), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, helpers in `lib.sh`. APK path: `infra/publish-apk.sh` on the Mac writes `infra/pi/out/apk/` (`version_code` = `git rev-list --count HEAD`, `version_name` = `YYYY.MM.DD-<short sha>` in Europe/Stockholm, passed as `-Pkvitt.versionCode` / `-Pkvitt.versionName`; copies `mapping.txt` as `kvitt-<version_code>.mapping.txt`; refuses a dirty tree and an unsigned APK); the owner `scp`s it to `/opt/kvitt/apk/`; `install-apk.sh` copies it into the `data` volume, writing `latest.json` last and keeping the newest 5; mappings stay outside the volume. `.env` keys grow by `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (read by `install.sh` only) and the informational `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. The §11.2 gate becomes `docker compose -f infra/pi/compose.yaml config`. Landed `5e027df`. Fulfils D-100. | M2c-pi report: D-100 left the layout, script names and the image and APK paths to M2c-pi. | SPEC §10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2 (M2c-pi), §11.3, §14.7; `infra/README.md` |
|
||||
| D-104 | 2026-10-18 | Images `${KVITT_REGISTRY}/kvitt-server`, `kvitt-cert-sync`, `kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/<name>`). `.gitea/workflows/build-images.yaml` follows the owner's working example: runner `host` (a Raspberry Pi 3, 1 GB; builds run on the runner itself), Alpine `apk add git docker-cli docker-cli-buildx`, a plain-HTTP buildx builder created once and reused, secret `kvitt_cicd`; triggers on `server/**`, `infra/cert-sync/**`, `infra/backup/**`, `.gitea/workflows/**` and `workflow_dispatch`; tags `v1.MINOR.PATCH` (the next patch after the newest tag) plus `latest`, the git tag pushed afterwards; `--build-arg CARGO_BUILD_JOBS=1` (honoured by `server/Dockerfile` since `965bb5d`; first build duration unknown). `infra/build-images.sh` on the Mac is the fallback (default tag the short SHA; builds locally and prints the push commands when `infra/secrets/registry.env` is absent). Both sidecars schedule with supercronic instead of busybox crond (crond runs no job as non-root, tested); the cert-sync image adds `openssl`. Pinned versions (2026-10-18): `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`, `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6`, openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; Docker Engine from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is 32-bit only), `docker-ce` 5:29.8.2. | M2c-pi report: the owner's example workflow pushes under the owner path with flat image names; busybox crond did not run jobs as a non-root user. | SPEC §10.1, §10.2, §10.3, §12 (OQ-1); `.gitea/workflows/build-images.yaml`, `infra/README.md` "Versions" |
|
||||
| D-102 | 2026-10-18 | S-7 editor options as fixed rows. Below the category quick row, account, date, payer and split are four full-width rows in a fixed order (Konto, Datum, Betalt av, Delning) instead of a wrapping chip row, so no option moves when a value changes. Rows form a segmented group (outer corners 24, inner 8, gap 3) in surface-container-high (secondary-container while the split section is open), each at least 48 dp and tappable in full. A row has a leading element in a 24 dp box, a label (label-large, muted) in a column as wide as the widest label, the value (body-large, one line, ellipsis) and the trailing affordance at the right edge (expand_more/expand_less, swap_horiz for the payer). At ≥ 1.5× font scale the label sits over the value, which takes up to two lines, and every row keeps the two-line height. TalkBack sentences are unchanged. S-9 and S-19 keep their chips (S-19 follows in M8e, D-107). Also: connected buttons get 8 dp side padding so 'Household' fits half of S-11's filter bar at 1x. Landed by M8d (`bacbd18`): `EditorOptionRows` in `:core:design`, `KvittDimens.connectedButtonSide`. | Owner finding on S-7, 2026-10-18: the chips moved when a value changed. | SPEC §13.3 (S-7), §13.4; `docs/ux/design-spec.md` §3.7, §3.10 |
|
||||
| D-103 | 2026-10-18 | The M2c-pi bundle `infra/pi/`: `compose.yaml` (was `infra/compose.yaml`), `.env.example` (was `infra/env.example`), `Caddyfile`, and the on-Pi scripts `install.sh` (idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secrets with their modes, adds `gitea.lan` to `insecure-registries`, sets the volume owners, starts the stack), `deploy.sh <tag> [--skip-backup]` (backs up before every deploy, because the Pi cannot tell whether a tag carries a migration; keeps `/opt/kvitt/previous-tag` for rollback), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, helpers in `lib.sh`. APK path: `infra/publish-apk.sh` on the Mac writes `infra/pi/out/apk/` (`version_code` = `git rev-list --count HEAD`, `version_name` = `YYYY.MM.DD-<short sha>` in Europe/Stockholm, passed as `-Pkvitt.versionCode` / `-Pkvitt.versionName`; copies `mapping.txt` as `kvitt-<version_code>.mapping.txt`; refuses a dirty tree and an unsigned APK); the owner `scp`s it to `/opt/kvitt/apk/`; `install-apk.sh` copies it into the `data` volume, writing `latest.json` last and keeping the newest 5; mappings stay outside the volume. `.env` keys grow by `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (read by `install.sh` only) and the informational `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. The §11.2 gate becomes `docker compose -f infra/pi/compose.yaml config`. Landed `2476667`. Fulfils D-100. | M2c-pi report: D-100 left the layout, script names and the image and APK paths to M2c-pi. | SPEC §10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2 (M2c-pi), §11.3, §14.7; `infra/README.md` |
|
||||
| D-104 | 2026-10-18 | Images `${KVITT_REGISTRY}/kvitt-server`, `kvitt-cert-sync`, `kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/<name>`). `.gitea/workflows/build-images.yaml` follows the owner's working example: runner `host` (a Raspberry Pi 3, 1 GB; builds run on the runner itself), Alpine `apk add git docker-cli docker-cli-buildx`, a plain-HTTP buildx builder created once and reused, secret `kvitt_cicd`; triggers on `server/**`, `infra/cert-sync/**`, `infra/backup/**`, `.gitea/workflows/**` and `workflow_dispatch`; tags `v1.MINOR.PATCH` (the next patch after the newest tag) plus `latest`, the git tag pushed afterwards; `--build-arg CARGO_BUILD_JOBS=1` (honoured by `server/Dockerfile` since `b4d9467`; first build duration unknown). `infra/build-images.sh` on the Mac is the fallback (default tag the short SHA; builds locally and prints the push commands when `infra/secrets/registry.env` is absent). Both sidecars schedule with supercronic instead of busybox crond (crond runs no job as non-root, tested); the cert-sync image adds `openssl`. Pinned versions (2026-10-18): `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`, `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6`, openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; Docker Engine from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is 32-bit only), `docker-ce` 5:29.8.2. | M2c-pi report: the owner's example workflow pushes under the owner path with flat image names; busybox crond did not run jobs as a non-root user. | SPEC §10.1, §10.2, §10.3, §12 (OQ-1); `.gitea/workflows/build-images.yaml`, `infra/README.md` "Versions" |
|
||||
| D-105 | 2026-10-18 | `backup` runs as uid 10001 on the Pi too, so it can open the WAL database (`sqlite3 .backup` needs the `-shm` file the server owns); `/opt/kvitt/secrets/rclone.conf` mode 0600 owned 10001:10001. `cert-sync` stays uid 1000 with `ha_ssh_key`, `ha_known_hosts` 0600 owned by 1000. `backup.sh` uses a temporary copy of the read-only `rclone.conf` so rclone can save refreshed tokens. `install.sh` chowns `/data`, `/data/apk`, `/backups` to 10001 and `/certs` to 1000 once. Reconciles SPEC §10.2; revises D-023 for `rclone.conf`. | M2c-pi report: uid 1000 could not back up the live database without loosening the server's files. | SPEC §10.2, §10.9; `infra/README.md` "Backup uid" |
|
||||
| D-106 | 2026-10-18 | `sync.sh` always requests the `tar -C <dir> -cf - fullchain.pem privkey.pem` stream, so both `authorized_keys` variants (A restricted, B plain) work unchanged; it refuses a key not matching its certificate and a certificate expiring before the installed one; an unreachable Caddy admin API at first start is not a failure (Caddy loads the files when it starts). `backup.sh` uploads every pending `kvitt-*` / `pictures-*` file including leftovers of failed runs, confirms each by name and size with `rclone lsf`, deletes the local copies only after all are confirmed, and writes `last-success` only after that. | M2c-pi report: the add-on's support for `authorized_keys` options is unknown; rehearsal of renew, refuse and failed-upload cases on the Mac. | SPEC §10.5.1, §10.9 |
|
||||
| D-107 | 2026-10-18 | S-19's options as fixed rows, extending D-102. The recurring template editor replaces its wrapping chips row (Konto · Betalt av · Delning) with `EditorOptionRows`: three full-width rows in the fixed order Konto, Betalt av, Delning, with no Datum row. They look and behave as in S-7: label column, value on one line with an ellipsis, label over value with a two-line value at ≥ 1.5× font scale, and Delning in secondary-container while the split section is open. A row's place and size never depend on its value. The schedule fields stay in the "Schema" section. TalkBack sentences are unchanged. A Robolectric layout test asserts identical row bounds for short and long values at 100 % and 200 %. The chip composables stay in `:core:design` for S-9 (`EditorChipRow`, `DateChip`); `AccountChip`, `PayerChip`, `SplitChip` were removed in `4755fe0`. Landed by M8e (`15397e4`). | Owner finding (D-102): chips moved when a value changed; S-19 had the same chips row. | SPEC §13.3 (S-19); `docs/ux/design-spec.md` §3.7, S-19 |
|
||||
| D-108 | 2026-10-18 | Navigation is instant. Every destination change (pushed screens, bottom-destination switches, S-7 and S-9 and the other editors, onboarding and the session gate) has no enter, exit, pop or size transition (`EnterTransition.None` / `ExitTransition.None`, no `SizeTransform`, set once on the app's `NavHost`, `NavTransitions` in `:app`). Replaces the Navigation bullet of design-spec §2.11. Motion inside a screen (ripples, state layers, expanding sections, FAB extend/collapse, bottom-sheet open, the seesaw and bunting) is unchanged. Not a contract change. Landed by M8f (`ed1b130`). | Owner finding on the S26, 2026-10-18: the default 700 ms fades made the app feel slow. | SPEC §13.2; `docs/ux/design-spec.md` §2.11 |
|
||||
| D-107 | 2026-10-18 | S-19's options as fixed rows, extending D-102. The recurring template editor replaces its wrapping chips row (Konto · Betalt av · Delning) with `EditorOptionRows`: three full-width rows in the fixed order Konto, Betalt av, Delning, with no Datum row. They look and behave as in S-7: label column, value on one line with an ellipsis, label over value with a two-line value at ≥ 1.5× font scale, and Delning in secondary-container while the split section is open. A row's place and size never depend on its value. The schedule fields stay in the "Schema" section. TalkBack sentences are unchanged. A Robolectric layout test asserts identical row bounds for short and long values at 100 % and 200 %. The chip composables stay in `:core:design` for S-9 (`EditorChipRow`, `DateChip`); `AccountChip`, `PayerChip`, `SplitChip` were removed in `1328afe`. Landed by M8e (`a59abeb`). | Owner finding (D-102): chips moved when a value changed; S-19 had the same chips row. | SPEC §13.3 (S-19); `docs/ux/design-spec.md` §3.7, S-19 |
|
||||
| D-108 | 2026-10-18 | Navigation is instant. Every destination change (pushed screens, bottom-destination switches, S-7 and S-9 and the other editors, onboarding and the session gate) has no enter, exit, pop or size transition (`EnterTransition.None` / `ExitTransition.None`, no `SizeTransform`, set once on the app's `NavHost`, `NavTransitions` in `:app`). Replaces the Navigation bullet of design-spec §2.11. Motion inside a screen (ripples, state layers, expanding sections, FAB extend/collapse, bottom-sheet open, the seesaw and bunting) is unchanged. Not a contract change. Landed by M8f (`956908a`). | Owner finding on the S26, 2026-10-18: the default 700 ms fades made the app feel slow. | SPEC §13.2; `docs/ux/design-spec.md` §2.11 |
|
||||
| D-109 | 2026-10-18 | S-16 shows a rejected change's lost version read-only. `Rejection` gains `lost: RejectedVersion?`, with `RejectedVersion(row: LedgerRow, accountName: String?, payer: User?, mode: SplitMode?, shares: List<Share>, notes: String, deleted: Boolean)` decoded in `:data` from `sync_rejections.rejected_payload` (the entity as pushed; `row` is the ledger row it would have been, `pending` false; for a settlement `accountName`, `payer` and `mode` are null and `shares` empty). `lost` is null when the payload cannot be decoded or its category or a user is not on the phone. "Öppna" on a rejection opens a dialog "Din version" (`home_sync_lost_title`, body `home_sync_lost_body`) with the read-only ledger row, account, payer, split and notes, the badge "Du tog bort den här" (`home_sync_lost_deleted`) for a refused deletion, and "Visa nuvarande version" (`home_sync_lost_show_current`), which opens S-8 or S-10; without `lost` it opens the current version as before. The `:core:design` ledger rows take a nullable `onClick` (null = read-only). Landed by M12f (`dc7a296`). UI contract change. | AC-coverage audit: S-16's "open a rejected change (read-only)" opened the current editable row, not the lost change. | SPEC §13.3 (S-16), §13.4, §14.2.4, §14.4.6; `docs/ux/design-spec.md` S-16 |
|
||||
| D-110 | 2026-10-18 | `AboutState` gains `schemaVersion: Int?`. `AboutRepositoryImpl` calls E-1 `/health` once when S-22 opens while online and takes `server_version` and `schema_version` from it; offline or when the call fails, `serverVersion` falls back to the `X-Kvitt-Server-Version` header of the last response and `schemaVersion` is null. S-22 shows "<version> (schema <n>)" (`settings_about_server_version_schema`, "1.1.0 (schema 3)"), the version alone without a schema. Landed by M12f (`dc7a296`). UI contract change. | AC-coverage audit: S-22 read the server version from a response header, while §13.3 says "from `/health`". | SPEC §13.3 (S-22), §13.4, §14.2.4, §14.3.4 (E-1); `docs/ux/design-spec.md` S-22 |
|
||||
| D-111 | 2026-10-18 | `SettlementRepository.start` and `recompute` return `SettleState?`: null when there is no paired session or the partner has not joined (S-9 is not offered then, AC-5.1). `save` returns `SaveResult.Invalid([FieldError("payer", "validation_failed")])` in that state. No method throws; `:data` has no `checkNotNull`/`requireNotNull` there. Landed by M12f (`dc7a296`). UI contract change. | M12f: `:data` threw (`checkNotNull`/`requireNotNull`) when S-9 was reached without a session or partner; failures are values in the UI contract. | SPEC §4 (AC-5.1), §14.2.4 |
|
||||
| D-112 | 2026-10-18 | A failed E-17 `PUT /devices/current/push-token` (network error, 429, 5xx) is kept by `PushTokenRetry` in `:app` and sent again after each successful sync until the server accepts it; a newer FID from `onRegistered` replaces the kept one. Landed by M12f (`2bd6b58`), `PushTokenRetryTest`. | M12f: a failed send was otherwise repeated only at the next app start's `register()`. | SPEC §4 (AC-13.7) |
|
||||
| D-113 | 2026-10-18 | `kvitt-server invite-partner`: what E-12 `POST /invites` with `kind: partner` does for the admin, run on the server host: cancels a pending `partner` invite, creates one by the admin (72 h, same code format), prints the code, the landing link naming the admin (`?from=`, D-087) and the expiry, like `bootstrap`; exit 0 ok, 2 household full, 3 no admin (nothing created); no audit entry (as E-12); the code goes to stdout only. The push sender logs one info line `push sent` per message with `push_type`, `devices_sent`, `devices_targeted` and the last 8 characters of each device id that got it; the per-device line stays at debug. Landed by the server gap pass (`8660b7f`), with CLI tests for `invite-partner`, `migrate`, `healthcheck` and the SIGTERM drain; `tools/contracts-check.py` follows `invite_kind`, `app_version_name` and `fid` (134 checks). Closes the ops request of the M8 phone test. | The M8 end-to-end test needed a second admin device to invite a scripted partner; push delivery was invisible at the default log level. | SPEC §8.7, §10.4, §11.3; `server/README.md`, `infra/README.md` |
|
||||
| D-114 | 2026-10-18 | Upload and token limits. A picture upload (E-9, AC-4.4) must also be at most 16 000 000 pixels (width × height, for example 4000 × 4000) and decode to at most 64 MB (16 MP × 4 bytes, so a 16-bit image is refused above 8 MP), checked from the image header before decoding with the decoder limits set as well (`MAX_PIXELS`, `MAX_DECODE_ALLOC` in `src/api/pictures.rs`), refused with `image_invalid`; the 8000 px per side and 5 MiB limits stay. `fcm_token` at E-17 is 1 to 512 characters after trimming (`FCM_TOKEN_MAX`); longer is `invalid_request` with field `fcm_token`, like the empty string (D-034); a Firebase Installation ID has 22 characters. Landed by the server overhaul (`baa5806`, `b985ce9`). | Security review M-3: 8000 × 8000 decodes to 256 MB RGBA, above the 160 MiB container. L-4: a device could store a token up to the 1 MiB body limit, sent to FCM in every push. | SPEC §4 (AC-4.4), §9.6, §14.3.4 (E-17) |
|
||||
| D-115 | 2026-10-18 | Rate-limit token acceptance, revising D-032: a bearer token is accepted only when a response to it is 2xx; a request with a token not yet accepted counts against its client IP's unauthenticated limit before it runs and is refused with `rate_limited` once the IP is over; a 2xx gives that count back; a 401 for an accepted token counts against the IP and withdraws the acceptance; token entries are pruned once their window expires, accepted or not; every 401 counts against the IP exactly once. Landed by the server overhaul (`725901c`), with unit tests per rule and an integration test of random tokens without the protocol header (60 × 426, then 429, the paired device unaffected). | Security review H-1: a random token without `X-Kvitt-Protocol` (426) became accepted for good, bypassed the IP limit and was never pruned, so a stranger could grow the in-memory map without bound. | SPEC §8.6 |
|
||||
| D-116 | 2026-10-18 | Notifications are posted with `VISIBILITY_PRIVATE` and a public version carrying the generic "Kvitt" / "Open Kvitt to see it" (`notif_generic_title`, `notif_generic_body`), so a secure lock screen shows no description, amount, account or name (new AC-13.9; `NotificationPrivacyTest`). L-6 (a link can prefill any server on S-1) stays as built: S-1 opens its server row expanded when the link's server is not the release default, release builds require `https://`, and a paired phone ignores links (D-084); a confirmation step would need a design state and is not in v1 (OQ-6). Landed by M12f (`2bd6b58`). | Security review L-7 and L-6. | SPEC §4 (AC-2.6, AC-2.8, AC-13.9), §12 (OQ-6) |
|
||||
| D-117 | 2026-10-18 | Infra security fixes, landed `7fb86f5`. (1) Caddy's access log and error log (`log default`) filter `request>uri` with ``regexp `^(/i/)[^?]+\|([?]).+` `${1}${2}REDACTED` `` (`wrap json`), so invite codes and the `?from=` name never reach a log; `Authorization` stays redacted by default. (2) Deferred response headers `Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"`, `X-Content-Type-Options nosniff`, `X-Frame-Options DENY`, `Referrer-Policy no-referrer`. (3) The `caddy` container runs as `10002:10002` with `group_add` 1000, `read_only`, `cap_drop: [ALL]` plus `cap_add: [NET_BIND_SERVICE]` (the image's binary has that file capability and cannot be exec'd without it), `no-new-privileges`, tmpfs `/config` and `/tmp`; `install.sh` sets `/certs` 0750 1000:1000 and `caddy_data` to 10002 (extends D-105). (4) The development stack mounts `infra/dev/secrets/` (git-ignored), into which `up.sh` copies only `fcm-service-account.json`. (5) `deploy.sh` and `install.sh` print each pulled image's digest and append `<UTC time> <KVITT_TAG> <image reference> <digest>` to `/opt/kvitt/deployed-digests`, with a WARNING when a recorded reference changed digest; the workflow prints the pushed digests. (6) `kvitt_cicd` needs only `write:package` (the tag push uses the checkout credentials). (7) The plain root key (variant B) for cert-sync is no longer acceptable: variant A (restricted) or a dedicated non-root user. (8) One-off check after the router forward that Caddy's `remote_ip` shows public addresses. | Security review at `18c527f`: L-1, L-2, L-5, L-9, M-2, I-5. | SPEC §8.7, §8.9, §10 intro, §10.2, §10.3, §10.5.1, §10.5.2, §10.10; `infra/pi/`, `infra/dev/`, `.gitea/workflows/build-images.yaml`, `infra/README.md` |
|
||||
|
||||
@@ -666,7 +666,7 @@ Each screen: layout top to bottom, the **binding states** (exactly the frames of
|
||||
* Code typing: uppercase, hyphens inserted after 4 and 8, normalised per §8.3; "Para ihop" enabled when the code has 12 characters, the name is non-blank (first pairing) and the server is a valid URL. IME `Next` from code to name, `Done` on the name runs "Para ihop".
|
||||
* "Klistra in": reads the clipboard; a 12-character code or an invite link (`kvitt://invite?…` or `https://…/i/<CODE>`) fills code (and server).
|
||||
* Other errors, all inline under the field at fault, button enabled again: network "Kunde inte nå servern. Kontrollera adressen och anslutningen." (under the server row, which expands); `rate_limited` "För många försök. Vänta en stund och försök igen." (under the code); `http://` in release (AC-2.8) "Adressen måste börja med https://" (server). Offline: the button stays enabled and gives the network error (pairing never queues).
|
||||
* Success: `pair(serverUrl, code, displayName, deviceName = Build.MODEL)` → partner pairing goes to S-2 (AC-2.10), otherwise S-4 in its loading state; after the first full sync the system notification-permission prompt (AC-2.9), preceded by nothing (the rationale string `home_notification_rationale` is used only if Android asks for a rationale).
|
||||
* Success: `pair(serverUrl, code, displayName, deviceName = Build.MODEL)` → partner pairing goes to S-2 (AC-2.10), otherwise S-4 in its loading state; after the first full sync the system notification-permission prompt (AC-2.9), preceded by nothing (no rationale screen).
|
||||
|
||||
**TalkBack**: the paste button reads its text "Klistra in" (`onboarding_code_paste`); the code field reads the value group by group; the server row "Serveradress, https://icehack.asuscomm.com:8443, dubbeltryck för att ändra"; errors are announced (live region assertive).
|
||||
|
||||
@@ -1042,7 +1042,7 @@ Full screen (§13.2), works offline (AC-8.2). The flow and the category rule are
|
||||
| `S-16-sync-clock` | variant clock skew | clock banner first; status "Synkad för 2 h sedan" / "Senaste försöket nekades: klockan" with `sync_problem`; pending rows; no rejection list |
|
||||
| `S-16-sync-empty` | empty | status card "Allt är synkat" (`home_sync_status_all_synced`) over "Senast lyckade synk 13:59"; "Synka nu"; no pending or rejection sections |
|
||||
|
||||
**Interactions**: "Öppna" opens the entity's current version (S-8 / S-10; deleted or missing states apply); "Ta bort från listan" → `dismiss` (works offline). The rows update live while a sync runs.
|
||||
**Interactions**: "Öppna" opens the lost version read-only (D-109) when `Rejection.lost` is set: a dialog with "Din version" (headline-small, `home_sync_lost_title`), "Så här såg det ut på telefonen. Det sparades inte och kan inte ändras här." (body-medium muted, `home_sync_lost_body`), the ledger row it would have been (`ExpenseRow` / `SettlementRow` with a null `onClick`, so it is not tappable), `FactRow`s for account, payer, split and notes where present, the badge "Du tog bort den här" (`delete`, `home_sync_lost_deleted`) with the facts at the deleted alpha when the refused change was a deletion, and "Visa nuvarande version" (`home_sync_lost_show_current`), which opens the current version (S-8 / S-10; deleted or missing states apply); without `lost` (unreadable payload, or its category or a user is not on the phone) "Öppna" opens the current version directly. "Ta bort från listan" → `dismiss` (works offline). The rows update live while a sync runs.
|
||||
|
||||
**TalkBack**: status card one node; rejection card one node plus its two buttons, read with their visible text ("Öppna", "Ta bort från listan", `home_sync_status_dismiss`).
|
||||
|
||||
@@ -1132,7 +1132,7 @@ Not a screen: the `UpdateBanner` on S-4 (and S-22) and one dialog.
|
||||
|
||||
### S-22 About (`:feature:settings`)
|
||||
|
||||
**Layout** (app bar: back, "Om Kvitt"; column gap 10): centred app mark 96 (Sunny, `primary`, `receipt_long` 48), "Kvitt 1.1 (11)" (headline-medium), "Delade utgifter för två" (body-medium muted); `UpdateBanner` when an update is available; group of `FactRow`s: Server (`dns`, URL wraps), Serverversion (`memory`, "1.1.0" or "Okänd" when `/health` failed); no open-source licences row (OQ-5, D-099); "Sök efter uppdatering" (tonal medium, `system_update`, centred; hidden in debug builds, AC-16.5).
|
||||
**Layout** (app bar: back, "Om Kvitt"; column gap 10): centred app mark 96 (Sunny, `primary`, `receipt_long` 48), "Kvitt 1.1 (11)" (headline-medium), "Delade utgifter för två" (body-medium muted); `UpdateBanner` when an update is available; group of `FactRow`s: Server (`dns`, URL wraps), Serverversion (`memory`, "1.1.0 (schema 3)" from `/health` when S-22 opens online, `settings_about_server_version_schema`; offline or when `/health` fails the last response's server-version header without the schema, "1.1.0", or "Okänd" when none is known; D-110); no open-source licences row (OQ-5, D-099); "Sök efter uppdatering" (tonal medium, `system_update`, centred; hidden in debug builds, AC-16.5).
|
||||
|
||||
**States**
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
> Taken at `ed1b130` (main, 2026-10-18, SPEC v0.16; `956908a` after the history rewrite, so the hashes below are pre-rewrite); committed verbatim with SPEC v0.18. Its two S-16 and S-22 mismatches are fixed since (D-109, D-110; SPEC §11.4).
|
||||
|
||||
# AC coverage audit: is Kvitt feature complete against SPEC.md?
|
||||
|
||||
Audited 2026-10-18 at `main` `ed1b130` (SPEC v0.16), read-only: code and tests were read (`git grep`, file reads); no build, no test run, no phone. Scope: SPEC §4 (FR-1 to FR-20, every AC), §9 rules, §13.3 screen actions S-1 to S-22, §14.2.4 UI contract, §14.3.4 endpoints E-1 to E-33, §14.4 sync, §14.6 push, §10.4 server CLI. Aggregates only; no personal data.
|
||||
|
||||
Status meanings: **Done** = implemented in the real path (`:data`, features, `:app`, server crates) and demonstrated by a test, or by the recorded M8 phone check; **Partial** = implemented but a sub-clause or the §4 evidence rule is not met; **Missing** = not present; **Not verifiable without device** = only on-device or on-Pi behaviour (system UI) can prove it.
|
||||
|
||||
## 1. Summary
|
||||
|
||||
| Status | Rows |
|
||||
|---|---|
|
||||
| Done | 260 |
|
||||
| Partial | 5 |
|
||||
| Missing | 1 |
|
||||
| Not verifiable without device | 4 |
|
||||
| **Total** | **270** |
|
||||
|
||||
Per part: server side (Part A, 98 rows): 95 Done, 2 Partial, 1 Missing. Android FR-2 to FR-12 and S-1 to S-10 (Part B, 100 rows): 96 Done, 1 Partial, 3 device-only. Android FR-9, FR-13 to FR-16, FR-19, S-11 to S-22, UI contract (Part C, 72 rows): 69 Done, 2 Partial, 1 device-only.
|
||||
|
||||
Further findings:
|
||||
- All 33 endpoints are routed (`server/crates/kvitt-server/src/router.rs:31-102`) and tested, and the OpenAPI equality test is in the server gate.
|
||||
- All six CLI subcommands of §10.4 exist.
|
||||
- Every S-1 to S-22 action reaches a real destination or repository call in `android/app/src/main/kotlin/se/hagstedt/kvitt/nav/KvittApp.kt`; none is wired to a no-op.
|
||||
- `:core:model` matches the §14.2.4 block exactly, the v0.13 additions included.
|
||||
|
||||
## 2. Verdict
|
||||
|
||||
**Feature complete in code: yes. Feature complete against SPEC §4's acceptance rule: no, not yet.**
|
||||
|
||||
Every functional requirement the app and server must implement is built. What remains falls into three groups:
|
||||
|
||||
**(a) Blocked on the Pi or the first release, which are ops milestones rather than code:**
|
||||
- AC-18.2 restore drill (Missing).
|
||||
- AC-18.1 nightly Drive backup observed on the Pi.
|
||||
- AC-20.1 latency under 100 ms on the Pi.
|
||||
- AC-16.3 the real PackageInstaller install.
|
||||
|
||||
All belong to M2c-pi or M10 by the milestone table.
|
||||
|
||||
**(b) Two spec mismatches in shipped behaviour:**
|
||||
- S-16 "open a rejected change (read-only)" opens the current editable row instead of the lost change.
|
||||
- S-22 reads the server version from a response header, not from `/health`.
|
||||
|
||||
**(c) Test-evidence gaps where the code reads correctly** (§9.2 effect line, AC-2.9, a few `:data` and sync-engine paths).
|
||||
|
||||
None of (b) or (c) blocks the code overhaul or the security review. The (a) items are the M10 gate itself.
|
||||
|
||||
Blockers for "feature complete":
|
||||
1. AC-18.2 restore drill report.
|
||||
2. S-16 read-only rejected-change view: implement it, or have a docs decision accept the summary line.
|
||||
3. S-22 server-version source: fix the code, or amend the spec.
|
||||
|
||||
The first release (M10) additionally needs AC-18.1, AC-20.1 and AC-16.3 shown on the Pi and phones.
|
||||
|
||||
## 3. Prioritised gaps (Missing and Partial first, then device-only, then evidence and robustness)
|
||||
|
||||
1. **AC-18.2 Restore drill (Missing).** `docs/validation/` does not exist, and no drill has been run; the procedure is in `infra/RESTORE.md`. Smallest change: once the Pi stack runs, or earlier on the dev stack as a rehearsal, follow `infra/RESTORE.md`:
|
||||
- restore the latest backup;
|
||||
- run `kvitt-server rotate-epoch`;
|
||||
- let both phones sync and confirm the epoch recovery;
|
||||
- write `docs/validation/restore-drill.md` with a "Results (owner's phone)" section.
|
||||
|
||||
Owner: M10 (first release), as an ops and docs agent plus the owner's taps.
|
||||
2. **S-16 "open a rejected change (read-only)" (Partial).** Evidence: `android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/SyncStatusScreen.kt` (RejectionCard, around line 274). The tap opens the ordinary editable S-8 or S-10 for the entity id, which holds the server's winning row (or the "missing" state). The lost local version in `sync_rejections.rejected_payload` is never shown, so "what was lost" is only the one-line summary. Smallest change: a read-only sheet on S-16 that renders the rejected payload. That needs a `Rejection` field, for example a list of display fields, so it is a contract change (§14.8) across `:core:model`, `:data`, `:core:fakes` and `:feature:home`. The alternative is a docs decision that the summary plus opening the current row satisfies §13.3. Owner: an M12-style contract pass, or a docs agent.
|
||||
3. **S-22 server version source (Partial).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt:336-345` reads the `X-Kvitt-Server-Version` header of the last response (`data/.../api/KvittApi.kt:241`), so the value is empty until a request reaches the server after process start. §13.3 says "from `/health`". Smallest change: call E-1 once when S-22 opens, in `AboutRepositoryImpl` (`:data`, no contract change), or amend the §13.3 wording. Owner: `:data`, or a docs agent.
|
||||
4. **AC-18.1 Nightly Drive backup (Partial).** The code is complete: 03:30 cron in `infra/backup/Dockerfile`, `rclone copy --immutable` in `infra/backup/backup.sh`. It has never run on the Pi. Smallest change: after the M2c-pi bundle is deployed, list the Drive folder the next morning and record the result in the restore-drill report. Owner: M2c-pi, run by the owner, then M10.
|
||||
5. **AC-20.1 Health under 100 ms on the Pi (Partial).** Function and test are done (`tests/skeleton/routes.rs` health tests); the latency is not measured. Smallest change: a timing loop against `/health` on the Pi, with the aggregate written into `docs/validation/first-release.md`. Owner: M10.
|
||||
6. **§9.2 Effect line (Partial: evidence).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/Snapshot.kt:124` `effectFor` reads correctly, but no `:data` test covers its four cases; the screenshots render the fakes. Smallest change: one `RepositoriesTest` case with four expenses asserting `Lent`, `Borrowed`, `PaidForYourself` and `NotInvolved`. Owner: `:data` (code-overhaul phase).
|
||||
7. **AC-16.3 PackageInstaller and "Install unknown apps" on the device (device-only).** `android/data/src/main/kotlin/se/hagstedt/kvitt/data/update/Updater.kt:177-209`; the size and SHA-256 checks are unit-tested. Smallest change: verify during M10 with a published signed APK, one owner yes/no. Owner: M10.
|
||||
8. **AC-2.9 Notification permission after the first sync (device-only, with no unit test).** `android/app/src/main/kotlin/se/hagstedt/kvitt/AppViewModel.kt:60-66`. Smallest change: an `:app` unit test with a fake `SyncRepository`, plus an owner yes/no on the next fresh pairing. Owner: `:app`.
|
||||
9. **AC-4.3 and AC-4.6 Photo picker and offline cached pictures (device-only).** Smallest change: two owner yes/no taps (pick a picture in Profile; in airplane mode, the pictures still show). Owner: orchestrator HUMAN steps.
|
||||
10. **AC-2.6 Stale invite held while paired (note).** A link opened while paired is ignored on screen but kept in `AppViewModel.invite`, and it prefills S-1 if the phone later becomes unpaired or revoked. Smallest change: drop it in `AppViewModel.onLink` while the session is `Paired`, with a unit test. Owner: `:app`.
|
||||
11. **AC-13.7 Push-token robustness (note).** A failed E-17 PUT is logged and not retried until Firebase calls `onRegistered` again, so a device could stay without pushes. Smallest change: re-send the stored FID at each paired start or after the first successful sync. Owner: `:app` push or `:adapter:fcm`.
|
||||
12. **AC-19.1 Enforcement (note).** Nothing in the gate rejects a string literal in feature code; a grep finds none today. Smallest change: a literal check in `build-logic`'s `ArchitectureCheckTask`. Owner: build-logic.
|
||||
13. **Missing tests on existing code (not AC gaps):**
|
||||
- `SyncEngineTest`: the 429 retry, and the D-040 whole-request refusal (400, 413, 409, 422 lead to the pull plus `last_error_code`).
|
||||
- `:data`: `CategoryRepositoryImpl.reorder` (one PATCH per changed `sort_order`), and the recurring `toWire` splits per mode.
|
||||
- The WorkManager unique names and policies.
|
||||
- The server CLI: `healthcheck`, `migrate`, and the 10 s SIGTERM drain of `serve`.
|
||||
|
||||
Owners: `:data`, and a server plan (code-overhaul phase).
|
||||
14. **AC-5.1 Home gate without a partner (note).** `KvittApp.kt:236-240` has no test; S-3 itself has screenshots and ViewModel tests. Low priority.
|
||||
|
||||
For the code overhaul and security review, not AC items:
|
||||
- `android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettlementRepositoryImpl.kt:49-50` uses `checkNotNull`, which throws in main code when there is no session or no partner. The UI gate prevents it today.
|
||||
- HANDOFF's ops request for a partner-invite `kvitt-server` subcommand would need a spec change first, because §10.4's subcommand list is exhaustive.
|
||||
|
||||
## 4. TODO(spec), TODO and FIXME in sources
|
||||
|
||||
`git grep -n -E 'TODO|FIXME'` over `android/`, `server/`, `infra/` and `tools/`:
|
||||
|
||||
| file:line | text | note |
|
||||
|---|---|---|
|
||||
| `android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt:72` | `TODO(spec): not stated in category-suggestion.md` | CS-5 final tie-break (lower category id). SPEC §9.11 CS-5 now states it, so the marker is stale |
|
||||
| `tools/contracts-check.py:326` | `TODO(spec), category-suggestion.md does not say.` | Same tie rule, also stale |
|
||||
|
||||
There is no other TODO or FIXME in android, server, infra or tools. The `XXX` hits are mktemp templates (`infra/cert-sync/sync.sh:36`, `infra/pi/lib.sh:22`, `infra/publish-apk.sh:59`) and a doc comment (`server/crates/kvitt-server/src/api/invites.rs:68`).
|
||||
|
||||
## 5. Coverage tables
|
||||
|
||||
### Part A: server side (kvitt-server, kvitt-domain, kvitt-import, infra)
|
||||
|
||||
Read at `ed1b130`. Paths are relative to `server/crates/kvitt-server/` unless they start with `server/`, `infra/` or `docs/`. Test evidence is given as `file::test_fn`. No build was run; the evidence comes from reading the code and the tests. Every route of §14.3.4 is registered in `src/router.rs:31-102`, and `tests/skeleton/routes.rs::every_route_is_registered_and_implemented` checks this.
|
||||
|
||||
#### Endpoints (§14.3.4)
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test fn name) | note |
|
||||
|---|---|---|---|---|
|
||||
| E-1 | `GET /health`: status, versions, db check; 503 `degraded` | Done | src/router.rs:32, src/api/health.rs; skeleton/routes.rs::health_reports_ok_with_versions, ::health_is_503_degraded_when_the_database_is_gone | the under-100 ms timing on the Pi is not measured (AC-20.1) |
|
||||
| E-2 | `GET /setup/status` `{initialized}` | Done | src/router.rs:33, src/api/setup.rs; skeleton/routes.rs::setup_status_follows_the_admin_user; core_api/invites.rs::e3_admin_setup_creates_the_admin_and_e2_turns_initialized | |
|
||||
| E-3 | `POST /invites/accept` with token, invite_kind, user, device, household; errors | Done | src/router.rs:34, src/api/invites.rs:234-; core_api/invites.rs::e3_* (6 tests) | |
|
||||
| E-4 | `GET /i/{code}` landing HTML, sv/en, `from`, 404 | Done | src/router.rs:35-37, src/api/landing.rs:203-207; core_api/landing.rs::e4_* (4 tests); sync_api/serve.rs::empty_invite_code_is_the_html_404_page | |
|
||||
| E-5 | `GET /apk/latest`, `not_found` before publishing | Done | src/router.rs:38, src/api/apk.rs; core_api/apk.rs::e5_not_found_until_published_then_read_on_every_request | |
|
||||
| E-6 | `GET /apk/files/{file_name}` | Done | src/router.rs:39; core_api/apk.rs::e6_serves_existing_files_matching_the_pattern_only | |
|
||||
| E-7 | `GET /me` | Done | src/router.rs:43, src/api/me.rs; core_api/me.rs::e7_returns_user_device_household_and_public_url | |
|
||||
| E-8 | `PATCH /me` display name | Done | src/router.rs:43; core_api/me.rs::e8_changes_the_own_display_name_with_a_new_server_version, ::e8_invalid_request_and_validation_failed | |
|
||||
| E-9 | `PUT /me/picture` (5 MiB layer) | Done | src/router.rs:44-49, src/api/pictures.rs:39-43; core_api/pictures.rs::e9_* (4 tests) | |
|
||||
| E-10 | `DELETE /me/picture` with `-removed` marker | Done | src/api/pictures.rs:255-273; core_api/pictures.rs::e10_removes_idempotently_and_the_next_upload_continues_the_count | |
|
||||
| E-11 | `GET /users/{id}/picture?size=` | Done | src/router.rs:50, src/api/pictures.rs:281-289; core_api/pictures.rs::e11_not_found_and_invalid_request, ::e9_crops_resizes_and_reencodes_and_e11_serves_both_sizes | |
|
||||
| E-12 | `POST /invites` (admin), link with `?from=` | Done | src/router.rs:51-54, src/api/invites.rs:369-387; core_api/invites.rs::e12_partner_invite_matches_the_example, ::e12_device_invite_for_either_user_and_its_errors | |
|
||||
| E-13 | `GET /invites`, pending only | Done | src/api/invites.rs:429; core_api/invites.rs::e13_lists_pending_invites_only | |
|
||||
| E-14 | `DELETE /invites/{id}`, idempotent | Done | src/api/invites.rs:473; core_api/invites.rs::e14_cancel_is_idempotent_and_its_errors | |
|
||||
| E-15 | `GET /devices`, admin all / member own | Done | src/api/devices.rs:31; core_api/devices.rs::e15_admin_sees_all_devices_member_own_ordered_by_created_at | |
|
||||
| E-16 | `DELETE /devices/{id}`, `cannot_revoke_current_device` | Done | src/api/devices.rs:61-72; core_api/devices.rs::e16_* (3 tests) | |
|
||||
| E-17 | `PUT /devices/current/push-token` | Done | src/api/devices.rs:100-110; core_api/devices.rs::e17_sets_and_clears_the_fcm_token, ::e17_invalid_request | |
|
||||
| E-18 | `POST /accounts` | Done | src/router.rs:65, src/api/accounts.rs; core_api/accounts.rs::e18_* (3 tests) | |
|
||||
| E-19 | `PATCH /accounts/{id}` | Done | core_api/accounts.rs::e19_renames_and_changes_the_ratio_like_the_example, ::e19_errors | |
|
||||
| E-20/E-21 | archive and unarchive an account, idempotent | Done | src/router.rs:70-77; core_api/accounts.rs::e20_e21_archive_and_unarchive_are_idempotent | |
|
||||
| E-22 | `DELETE /accounts/{id}`, `account_not_empty` | Done | core_api/accounts.rs::e22_soft_deletes_an_empty_account, ::e22_account_not_empty_for_expense_settlement_or_template | |
|
||||
| E-23 | `POST /categories` | Done | core_api/categories.rs::e23_creates_with_sort_order_max_plus_10, ::e23_invalid_request_and_validation_failed | |
|
||||
| E-24 | `PATCH /categories/{id}` (rename, reset, reorder) | Done | core_api/categories.rs::e24_renames_a_default_and_reorders_like_the_example_then_resets_the_name, ::e24_errors | |
|
||||
| E-25/E-26 | archive and unarchive a category | Done | core_api/categories.rs::e25_e26_archive_and_unarchive | |
|
||||
| E-27 | `DELETE /categories/{id}` | Done | core_api/categories.rs::e27_deletes_unused_custom_categories_only, ::e27_category_in_use_by_expense_or_template | |
|
||||
| E-28 | `POST /recurring-templates`, server mints the id, due period generated before reply | Done | src/api/recurring.rs; core_api/recurring.rs::e28_* (4 tests); core_api/generation.rs::catch_up_generates_every_due_period_on_create_with_audit_and_push | |
|
||||
| E-29 | `PATCH /recurring-templates/{id}` | Done | core_api/recurring.rs::e29_pauses_like_the_example_and_edits_fields, ::e29_errors | |
|
||||
| E-30 | `DELETE /recurring-templates/{id}` | Done | core_api/recurring.rs::e30_soft_deletes_and_then_not_found | |
|
||||
| E-31 | `GET /sync` pull | Done | src/router.rs:100, src/sync/pull.rs; sync_api/pull.rs (7 tests) | |
|
||||
| E-32 | `POST /sync` push | Done | src/sync/push.rs; sync_api/push_rules.rs (25 tests) | |
|
||||
| E-33 | `POST /settlements/proposal` | Done | src/router.rs:101, src/api/proposal.rs; core_api/proposal.rs::e33_* (4 tests) | |
|
||||
| OpenAPI | the generated document equals `contracts/openapi.yaml` | Done | tests/openapi_contract.rs::generated_document_equals_contract | |
|
||||
|
||||
#### CLI (§10.4)
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test fn name) | note |
|
||||
|---|---|---|---|---|
|
||||
| CLI-serve | migrations, scheduler, push sender, HTTP; SIGTERM drain 10 s; exit 0 | Done | src/cli/mod.rs:95-131, :25 | no automated test of the SIGTERM drain; used on the dev stack |
|
||||
| CLI-bootstrap | FR-1; exit 0, or 2 when already initialized | Done | src/cli/bootstrap.rs:27-74; core_api/cli.rs::bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_refuses | |
|
||||
| CLI-pair-device | `--user`; exit 0, or 3 for an unknown user | Done | src/cli/pair_device.rs:19-64; same cli.rs test (lines 77-98) | |
|
||||
| CLI-rotate-epoch | new random epoch on stdout; exit 0 | Done | src/cli/rotate_epoch.rs; sync_api/serve.rs::rotate_epoch_sets_a_new_random_epoch_and_exits_0 | |
|
||||
| CLI-healthcheck | GET 127.0.0.1:<port>/health; exit 0 on 200, else 1 | Done | src/cli/healthcheck.rs:16-59; infra/pi/compose.yaml:65-66, infra/dev/compose.yaml:52-53 | no unit or integration test of the subcommand itself |
|
||||
| CLI-migrate | runs migrations only; exit 0 | Done | src/cli/mod.rs:88-93 | the CLI path is untested; `db::migrate` is tested in skeleton/migration.rs |
|
||||
|
||||
#### Acceptance criteria (server side)
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test fn name) | note |
|
||||
|---|---|---|---|---|
|
||||
| AC-1.1 | bootstrap prints the code, link and expiry; exit 0 | Done | src/cli/bootstrap.rs:42-49; core_api/cli.rs::bootstrap_prints_… | |
|
||||
| AC-1.2 | users exist: `already initialized` on stderr, exit 2 | Done | src/cli/bootstrap.rs:65-67; cli.rs lines 67-75 | |
|
||||
| AC-1.3 | rerunning cancels the previous admin_setup invite | Done | src/cli/bootstrap.rs:35; cli.rs line 61 | |
|
||||
| AC-1.4 | accepting admin_setup creates the admin, the device and a token | Done | core_api/invites.rs::e3_admin_setup_creates_the_admin_and_e2_turns_initialized | |
|
||||
| AC-1.5 | `/setup/status` false, then true | Done | skeleton/routes.rs::setup_status_follows_the_admin_user | |
|
||||
| AC-1.6 | `pair-device --user` creates a device invite | Done | core_api/cli.rs lines 77-98 | |
|
||||
| AC-2.1 (server) | partner invite: `household_full`; a new one cancels the pending one | Done | src/api/invites.rs:385-387; core_api/invites.rs::e3_invite_invalid_for_a_cancelled_code_and_one_hour_before_expiry_it_works, ::e12_device_invite_for_either_user_and_its_errors | |
|
||||
| AC-2.3 | single use, 72 h, uniform `invite_invalid` | Done | src/api/invites.rs:33, :178; core_api/invites.rs::e3_invite_invalid_for_unknown_expired_used_cancelled_and_malformed_codes | |
|
||||
| AC-2.4 (server) | accept returns token, invite_kind, user, device, household; partner creates the member | Done | src/api/invites.rs:234-347; core_api/invites.rs::e3_partner_creates_the_member_and_device_pairs_an_existing_user | |
|
||||
| AC-2.5 | landing page sv/en, buttons, grouped code, `from` passthrough, no-referrer, no-store, 404 | Done | src/api/landing.rs:203-207; core_api/landing.rs (4 tests) | |
|
||||
| AC-2.12 | more than 10 failed accepts per IP per 60 min give `rate_limited` | Done | src/api/rate_limit.rs; core_api/rate_limit.rs::ten_failed_invite_accepts_lock_the_ip_for_the_rest_of_the_hour | |
|
||||
| AC-3.2 (server) | revoke rules: admin any, member own, never the current device | Done | src/api/devices.rs:61-72; core_api/devices.rs::e16_* | |
|
||||
| AC-3.3 (server) | revoked device gets 401 `device_revoked` | Done | src/auth.rs:127-136; core_api/devices.rs::e16_admin_revokes_another_device_which_then_gets_device_revoked | the phone side is out of this part |
|
||||
| AC-3.4 | a revoked device gets no pushes | Done | src/push/sender.rs:87,94 (`revoked_at IS NULL`); push_fcm/fcm.rs::recipients_are_the_other_users_live_devices_with_a_token_recurring_goes_to_both | |
|
||||
| AC-3.5 | never deleted; `revoked_at` set, `fcm_token` cleared | Done | src/api/devices.rs:72; devices.rs test lines 60-70 | |
|
||||
| AC-4.4 | image rules: types, 5 MiB, 8000 px, EXIF, crop, JPEG 85 at 512 and 128, strip, flatten, version | Done | src/api/pictures.rs:3-43,73; core_api/pictures.rs::e9_* | |
|
||||
| AC-5.2 | `household_incomplete` for accounts, templates, expense and settlement mutations | Done | src/api/rows.rs:105-110; accounts.rs::e18_household_incomplete_before_the_partner_joins; recurring.rs::e28_household_incomplete; push_rules.rs::step2_household_incomplete_fails_the_whole_request | |
|
||||
| AC-6.2 | names unique, case-insensitive after trimming, `name_taken` | Done | src/api/accounts.rs:20-31; accounts.rs::e18_invalid_request_validation_failed_and_name_taken | |
|
||||
| AC-6.5 (server) | delete only when empty (expense, allocation, template); tombstone | Done | core_api/accounts.rs::e22_account_not_empty_for_expense_settlement_or_template | |
|
||||
| AC-7.2 | fourteen seeded defaults | Done | migrations/0001, 0002; skeleton/migration.rs::a_fresh_database_holds_exactly_the_fourteen_defaults | |
|
||||
| AC-7.3 | `sort_order` = max + 10 | Done | core_api/categories.rs::e23_creates_with_sort_order_max_plus_10 | |
|
||||
| AC-7.6 (server) | `category_in_use`, `category_is_default`, soft delete | Done | core_api/categories.rs::e27_* | |
|
||||
| AC-9.2 | 06:00 Europe/Stockholm, days 29-31 clamp | Done | core_api/generation.rs::occurrence_is_at_0600_europe_stockholm_in_summer_and_winter, ::days_29_30_31_clamp_to_the_last_day_including_february_2027 | |
|
||||
| AC-9.3 / §9.5 | catch-up, idempotency, split resolution, pause and resume without backfill, clock jumps | Done | generation.rs::catch_up_…, ::an_existing_period_is_skipped_silently_by_the_unique_index, ::mode_default_resolves_the_account_ratio_at_generation_time, ::modes_ratio_full_and_exact_use_the_template_splits, ::paused_generates_nothing_and_resume_does_not_backfill, ::resume_before_start_period_keeps_the_start, ::end_period_stops_generation_and_next_occurrence_becomes_null; scheduler_daily/scheduler.rs::runs_at_start_then_at_06_00_and_survives_clock_jumps, ::next_06_00_is_local_time_across_both_dst_changes | |
|
||||
| AC-9.4 (server) | template edits affect only later expenses | Done | generation.rs::archived_account_continues_and_edits_affect_only_later_expenses | |
|
||||
| AC-9.5 | a deleted generated expense is not recreated | Done | generation.rs::a_deleted_generated_expense_is_not_recreated | |
|
||||
| AC-9.6 | audit `source` recurring; push to both users | Done | sync_api/audit.rs::generation_writes_recurring_audit_with_server_now_and_no_device; push_fcm/fcm.rs::recipients_…_recurring_goes_to_both | |
|
||||
| AC-9.7 (server) | `next_occurrence_date` computed by the server | Done | kvitt-domain/src/recurring.rs; generation.rs::end_period_stops_generation_and_next_occurrence_becomes_null | |
|
||||
| AC-11.8 | E-33 equals `propose_allocation`, shared vectors | Done | kvitt-domain/tests/vectors.rs::every_vector_file_passes; core_api/proposal.rs::e33_matches_the_example, ::e33_honours_exclude_settlement_id_and_settlements | |
|
||||
| AC-12.1 | audit for every accepted create, update, delete (app, scheduler, importer); append-only | Done | src/audit/mod.rs; sync_api/audit.rs::entries_are_append_only_create_update_delete; kvitt-import tests/import.rs::english_export_imports_expenses_settlements_and_audit | |
|
||||
| AC-12.2 | entry fields: actor device null for scheduler and importer, client_at, server_at, diff §14.4.7 | Done | src/audit/diff.rs (with unit tests); sync_api/audit.rs; push_rules.rs::step11_newer_update_writes_the_diff_and_keeps_server_owned_fields | |
|
||||
| AC-13.1 | push to the other user's live devices with a token | Done | src/push/sender.rs:87-94; push_fcm/fcm.rs::recipients_…; sync_api/e2e.rs::create_on_device_a_pull_on_device_b_row_audit_and_one_push | |
|
||||
| AC-13.2 | recurring generation goes to both users | Done | push_fcm/fcm.rs::recipients_…_recurring_goes_to_both | |
|
||||
| AC-13.3 | the importer sends no pushes | Done | server/crates/kvitt-import/src/db.rs:4 (no push queue in the crate) | true by construction; no explicit test |
|
||||
| AC-13.4 | more than 3 notifiable changes give one `sync_batch` | Done | src/sync/push.rs:36,713-715; push_rules.rs::more_than_3_notifiable_changes_send_one_sync_batch_3_send_each | |
|
||||
| AC-13.7 (server) | sends to `message.fid` | Done | src/push/fcm.rs:116-121; push_fcm/fcm.rs::payload_of_every_type_equals_the_contract_example | |
|
||||
| AC-13.8 | no personal content in the payload | Done | push_fcm/fcm.rs::payload_of_every_type_equals_the_contract_example; core_api/logging.rs::logs_hold_no_personal_content | |
|
||||
| §14.6 queue | bounded at 100, the oldest dropped | Done | src/push/queue.rs:12,62; push_fcm/fcm.rs::full_queue_drops_the_oldest | |
|
||||
| §14.6 auth | RS256 JWT, token cached until expiry minus 5 min, 401 drops the token | Done | src/push/fcm.rs:268,302; fcm.rs::oauth_jwt_and_send_request_follow_the_http_v1_flow, ::access_token_is_cached_until_5_minutes_before_expiry | |
|
||||
| §14.6 retries | 1 s, 5 s, 25 s on 429, 5xx, network | Done | src/push/sender.rs:26-28; fcm.rs::production_retry_delays_are_1_5_25_seconds, ::retries_429_and_5xx_…, ::network_errors_are_retried | |
|
||||
| §14.6 unregistered | `UNREGISTERED` or 404 clears the token if unchanged | Done | src/push/fcm.rs:93,135-142,296; fcm.rs::unregistered_or_404_clears_the_devices_token | |
|
||||
| §14.6 disabled | missing credentials: Noop, one warning | Done | fcm.rs::missing_or_unusable_credentials_disable_push | |
|
||||
| §14.4.2 pull | cursor_ahead, params, order, paging, tombstones, epoch | Done | src/sync/pull.rs; sync_api/pull.rs (7 tests) | |
|
||||
| §14.4.4 pre-checks | 0 or more than 100 is `batch_too_large`, 1 MiB, invalid envelope | Done | push_rules.rs::batch_of_101_is_batch_too_large_and_0_too_and_100_is_fine, ::body_over_1_mib_is_payload_too_large, ::malformed_body_or_envelope_is_invalid_request | |
|
||||
| §14.4.4 steps 1-4 | replay, household, shape, clock_skew | Done | push_rules.rs::step1_…, ::duplicate_mutation_ids_…, ::step2_…, ::step3_…, ::step4_… | |
|
||||
| §14.4.4 steps 5-8 | tombstone, delete wins, never-synced tombstone, validation and references | Done | push_rules.rs::step6_…, ::step7_… (2), ::step8_… (5) | |
|
||||
| §14.4.4 steps 9-12 | insert, stale, diff update, empty diff, record mutation | Done | push_rules.rs::step9_…, ::step10_…, ::step11_… (2), ::step12_…; sync_api/conflicts.rs (5 tests) | |
|
||||
| AC-15.6 (server) | 14 days offline with 200 changes syncs | Done | sync_api/offline.rs::fourteen_days_offline_200_changes_all_sync | the phone side is out of this part |
|
||||
| §14.1 pruning | `sync_mutations` older than 90 days pruned at 04:00 | Done | src/tasks/scheduler.rs:106-109; scheduler.rs::pruning_deletes_rows_older_than_90_days_and_keeps_the_rest, ::pruning_runs_at_04_00_local | |
|
||||
| §8.6 | 60/min per IP, 600/min per device, XFF from the trusted proxy only, body limits 1 MiB and 5 MiB | Done | src/router.rs:22-24,108-109; core_api/rate_limit.rs (5 tests); sync_api/serve.rs::two_client_addresses_get_separate_rate_limit_buckets_end_to_end | |
|
||||
| AC-17.1 | `kvitt-import` CLI, one file per group into a named account | Done | server/crates/kvitt-import/src/cli.rs:87-114; tests/import.rs::cli_end_to_end_exit_codes, ::cli_help_and_usage_errors | |
|
||||
| AC-17.2 | a rerun creates nothing (`import_key`) | Done | kvitt-import/src/db.rs:111,345; import.rs::rerun_creates_nothing_and_existing_account_is_reused | |
|
||||
| AC-17.3 | fixed category table; unknown names map to `other` and are listed | Done | kvitt-import/data/splitwise_categories.csv; import.rs::category_table_matches_the_spec, english_export… lines 198-262 | |
|
||||
| AC-17.4 | payments become settlements with one allocation | Done | import.rs lines 296-300 | |
|
||||
| AC-17.5 | `--dry-run` rolls back, same counts and exit status | Done | import.rs::dry_run_writes_nothing_and_reports_the_same_counts | |
|
||||
| AC-17.6 | audit `source` import, no pushes | Done | kvitt-import/src/db.rs:300; import.rs lines 335- | |
|
||||
| AC-18.1 | nightly 03:30 snapshot and pictures archive, gzip, rclone to Drive with `--immutable` | Partial | infra/backup/Dockerfile:22-27, infra/backup/backup.sh:27-69, infra/pi/compose.yaml (backup service) | implemented; never run on the Pi (M2c-pi is owner-run, D-100); no automated test |
|
||||
| AC-18.2 | restore drill documented in `docs/validation/restore-drill.md` | Missing | infra/RESTORE.md (88 lines, the procedure) | `docs/validation/` does not exist; the drill has not been run |
|
||||
| AC-20.1 | `/health` unauthenticated, in under 100 ms on the Pi | Partial | skeleton/routes.rs::health_* | function Done; the 100 ms target on the Pi is not measured (not verifiable without the Pi) |
|
||||
| AC-20.2 | the compose healthcheck of `server` uses it | Done | infra/pi/compose.yaml:65-66; infra/dev/compose.yaml:52-53 | |
|
||||
|
||||
|
||||
### Part B: Android side, FR-2 to FR-12, §9.2/§9.8/§9.9/§9.11, screens S-1 to S-10 (main at ed1b130)
|
||||
|
||||
Convention: Done = code present in the real `:data`/feature/`:app` path and a test (unit, screenshot or the M8 phone check) demonstrates it; Partial = code present but the §4 evidence rule is not met, or a sub-clause missing; Not verifiable without device = only an on-device behaviour (system UI) proves it. Test names are JUnit method names in the cited file. Paths are relative to `android/`.
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test name) | note |
|
||||
|---|---|---|---|---|
|
||||
| AC-2.1 (phone) | Admin creates a partner invite from Settings; new one cancels pending | Done | feature/settings/.../invites/InvitesViewModel.kt:88 `invitePartner`; hub/SettingsHubViewModel.kt:47; StateHolderTest.invitesCreateAndRefuse | `household_full`, cancel-previous are server rules (fork A) |
|
||||
| AC-2.2 (phone) | Admin creates a device invite for either user | Done | InvitesViewModel.kt:91 `inviteDevice(user)`; InvitesScreen.kt:198 | |
|
||||
| AC-2.4 (phone) | Accept takes code, device name, display name; returns kind, user, household | Done | data/.../repo/SessionRepositoryImpl.kt; SessionRepositoryTest.pair_storesCredentials_writesTheHousehold_andStartsAFullSync, pair_reportsTheInviteKind_… | |
|
||||
| AC-2.6 | `kvitt://invite` and `https://…:8443/i/<CODE>` open S-1 prefilled; `from` names the admin; ignored while paired | Done | app/src/main/AndroidManifest.xml:31-47 (`autoVerify="false"`, scheme kvitt host invite, https host/port/pathPrefix /i/); app/.../link/AppLinks.kt; nav/KvittApp.kt:210-221 (prefill), :130-137 (S-1 only when unpaired); AppLinksTest.appLinkFillsServerAndCode, landingLinkFillsTheServerFromTheLink, linksWithTheAdminNameCarryIt_andOldLinksStillParse; InviteLinkTest | "Ignored while paired" holds because S-1 is not in the paired graph; the parsed invite stays in `AppViewModel.invite` (AppViewModel.kt:47-49) and would prefill S-1 if the phone later becomes unpaired. No test of the paired case. Chooser behaviour is device-only |
|
||||
| AC-2.7 | Manual entry; URL prefilled in release; code normalised per §8.3 | Done | feature/onboarding/.../ServerAddress.kt:7; PairingViewModelTest.theCodeIsNormalisedWhileTyping, aFirstPairingSendsTheNormalisedCodeAndTheTrimmedName; ComponentLogicTest.inviteCodesAreNormalisedPerSpec83 | |
|
||||
| AC-2.8 | Release https only; debug also http for localhost/10.0.2.2/private LAN, in `normaliseServerUrl` | Done | SessionRepositoryTest.serverUrls_releaseHttpsOnly_debugAlsoLocalHttp; ServerAddressTest.debugAcceptsHttpOnlyForLocalAndPrivateAddresses; PairingViewModelTest.releaseRefusesHttpBeforeCallingTheServerAndDebugAllowsTheLan | |
|
||||
| AC-2.9 | Full sync after pairing, then ask POST_NOTIFICATIONS | Not verifiable without device | app/.../AppViewModel.kt:60-66 (waits for `lastSuccessAt != null && !running`); nav/KvittApp.kt:104-116 | No unit test of AppViewModel; the system prompt is device-only |
|
||||
| AC-2.10 | Partner pairing goes to S-2; every other pairing to Home | Done | nav/KvittApp.kt:213-217; PairingViewModelTest.aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin, theAdminGoesHomeAndTheAgainVariantSendsNoName | |
|
||||
| AC-2.11 | Admin sees pending invites (kind, created, expiry) and cancels each | Done | InvitesScreen.kt:223-263; InvitesViewModel.kt:118 `cancel`; DevicesInvitesAboutScreenshotTest | |
|
||||
| AC-3.1 | Devices list: name, owner, paired, last seen, app version name else code, "This device" | Done | feature/settings/.../devices/DevicesScreen.kt; DevicesViewModel.kt:77; StateHolderTest.devicesLoadFailAndRevoke; DevicesInvitesAboutScreenshotTest | Revoked devices are filtered out of the list (DevicesViewModel.kt:77); the spec does not ask to show them |
|
||||
| AC-3.2 (phone) | Admin revokes any but current; member own but current | Done | DevicesViewModel.kt:39-40 `canRevoke`; PureRulesTest.revokeRules | Server enforces too (fork A) |
|
||||
| AC-3.3 | On 401 device_revoked: delete token, DB, cached pictures; show S-18 "Pair again" | Done | data/.../di/DataModule.kt:166-170 (clearAllTables, Coil memory and disk cache); auth/SessionManager.kt:35; SyncEngineTest.deviceRevoked_wipesTokenAndLocalData; CredentialStoreTest.revokedDeletesTokenAndKey_unrecognisedKeepsTheUser; KvittApp.kt:133, :226-231 | |
|
||||
| AC-4.1 | Display name and optional picture | Done | model User(displayName, pictureVersion) | |
|
||||
| AC-4.2 | Edit own display name in Settings, online only | Done | feature/settings/.../profile/ProfileViewModel.kt; StateHolderTest.profileNameRulesAndSave, profileOfflineDisablesEverything | |
|
||||
| AC-4.3 | Picture from the Android photo picker or removed; online only | Not verifiable without device | profile/ProfileScreen.kt:59-74 and onboarding ProfileStepScreen.kt:96-117 (`PickVisualMedia`); StateHolderTest.profileRefusedNameIsInlineAndPictureErrorsAreSnackbars; OtherViewModelsTest.offlineThePictureCannotChange | Picker itself is system UI |
|
||||
| AC-4.5 | Initials on avatar-1/avatar-2, slot a stable function of the user id | Done | core/design/.../component/People.kt:55; ComponentLogicTest.initialsAreTheFirstLettersOfTheFirstTwoWords, avatarSlotIsTheLowestIdFirstOnBothPhones | |
|
||||
| AC-4.6 | Pictures cached on disk (Coil) keyed by user id and picture_version | Not verifiable without device | data/.../repo/Pictures.kt:26-38 (disk cache, URL carries user id and `v=`) | Offline display not tested |
|
||||
| AC-5.1 | Without partner, Home is "Waiting for your partner" with "Invite partner"; no creation offered | Done | nav/KvittApp.kt:236-240; feature/onboarding WaitingScreen.kt; OtherViewModelsTest.waitingShowsThePendingInviteExpiryOnlyOnline; OnboardingScreenshotTest | Gate in `:app` not unit-tested |
|
||||
| AC-6.1 | Create account online, starts 50/50, percent with two decimals, other = 100 − first | Done | feature/account AccountEditorViewModel.kt; AccountViewModelTest.newAccountStartsAtFiftyFiftyAndNeedsAName, percentsAcceptTwoDecimalsAndTheOtherFollows, createSavesBasisPointsAndReportsTheNewId | |
|
||||
| AC-6.2 (phone) | Duplicate name shows `name_taken` | Done | AccountViewModelTest.nameTakenIsShownUnderTheName | Rule is server-side |
|
||||
| AC-6.3 | Rename or change ratio online; ratio change affects only later expenses | Done | AccountViewModelTest.editStartsFromTheAccount; default ratio copied into the expense at save (ExpenseRepositoryImpl.kt save; RepositoriesTest.preview_usesTheAccountDefault) | |
|
||||
| AC-6.4 | Archive/unarchive online; hidden from pickers; collapsed on Home; still in balance, analytics, settle | Done | data ExpenseRepositoryImpl.kt:57 (pickers active only); HouseholdRepositories.kt:100-101; feature/settle SettleViewModel.kt:81-82 (archived addable); AccountViewModelTest.archiveNavigatesBackWithTheName, unarchiveStaysWithASnackbar, archivedAccountHidesTheFab; HomeViewModelTest.archivedSectionToggles | Undo snackbar wired: KvittApp.kt:289-293 |
|
||||
| AC-6.5 (phone) | Delete only when no live expense, allocation or template references it | Done | data HouseholdRepositories.kt:151-154 `accountDeletable`; AccountViewModelTest.emptyAccountMayBeDeletedOnlyOnline, deleteOfANonEmptyAccountIsRefused, deleteAsksThenDeletes | |
|
||||
| AC-6.6 | Accounts by created_at ascending | Done | data Snapshot.kt:90 | |
|
||||
| AC-6.7 | Account create/edit disabled offline with "Needs connection" | Done | AccountEditorScreen.kt:171; AccountViewModelTest.offlineCannotSave, offlineActionSaysNeedsConnection | |
|
||||
| AC-7.1 | One global editable list: name, icon token, colour token | Done | model Category; ContractTest.categoryIconsAreTheThirtyThreeTokensOfSpec9_7InOrder | |
|
||||
| AC-7.4 | Rename, re-icon, recolour, reorder; "Reset name" for defaults | Done | StateHolderTest.categoryResetNameSavesNull, categoryReorderFailureRestoresTheOrder; CategoriesScreenshotTest | |
|
||||
| AC-7.5 | Archived categories not offered for new expenses/templates, kept on existing ones and analytics | Done | data ExpenseRepositoryImpl.kt:58; ComponentLogicTest.quickRowSkipsArchived; StateHolderTest.archiveReturnsWithASnackbarForTheList | |
|
||||
| AC-7.6 (phone) | Delete only if unused and not default; reason shown | Done | EditorCategoriesTest.deletable_falseForDefaults_unknownAndDeleted, deletable_followsExpensesAndTemplates_archivedStillCounts; StateHolderTest.categoryDeleteAffordanceFollowsDeletable | |
|
||||
| AC-7.7 | No uniqueness rule for category names | Done | no duplicate check in feature/settings categories (CategoryEditorViewModel.kt) | Absence of a rule |
|
||||
| AC-8.1 | Expense fields as listed | Done | model ExpenseDraft / ExpenseDetailState; data db/Entities.kt | |
|
||||
| AC-8.2 | Create/edit/delete any expense online or offline; UUIDv7 client id | Done | RepositoriesTest.expenseSave_writesAPendingRowWithAMutationIdAndTimestamps; ExpenseEditorViewModelTest.offlineSave_isAllowed_andReportsOffline; data util/Ids.kt | |
|
||||
| AC-8.3 | Payer either user, defaults to current user | Done | ExpenseEditorViewModelTest.newExpense_appliesTheDefaults | |
|
||||
| AC-8.4 | Any date within limits, default today in phone zone | Done | ExpenseEditorViewModelTest.dateTooFarAhead_comesBackAsValidationFailed, newExpense_appliesTheDefaults | |
|
||||
| AC-8.5 | Split modes default/ratio/exact/full, shares per §9.1 | Done | domain Split.kt; VectorTest (compute_split.json); ExpenseEditorViewModelTest.ratio_…, full_defaultsToThePersonWhoDidNotPay | |
|
||||
| AC-8.6 | Editor defaults: last-used account else oldest active; category suggestion then last-used else `other`; none if `other` archived | Done | data ExpenseRepositoryImpl.kt:54-74; RepositoriesTest.editorDefaults_lastUsedAccountAndCategory_elseOldestActiveAndOther; ExpenseEditorViewModelTest.noDefaultCategory_showsNoCategory_andBlocksSaving | |
|
||||
| AC-8.7 | Exact mode: other share read-only; typed > amount blocks saving | Done | ExpenseEditorViewModelTest.exactShareLargerThanTheAmount_blocksSaving | |
|
||||
| AC-8.8 | Account change in default mode re-copies ratio; other modes kept | Done | ExpenseEditorViewModelTest.accountChange_inDefaultMode_recopiesTheRatio | |
|
||||
| AC-8.9 | Delete asks for confirmation, soft delete, gone from lists/balances/analytics | Done | ExpenseDetailViewModelTest.delete_asksThenDeletes; RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones, analytics_includePending_excludeDeleted; SyncEngineTest.delete_pushesTheFullEntityWithDeletedAt_andKeepsTheTombstone | |
|
||||
| AC-8.10 | Rows: icon, colour, description, date, amount, effect line; date desc then created_at desc | Done | core/design component/Ledger.kt, Money.kt:137-148; data HouseholdRepositories.kt:157-166 (ledgerOrder); AccountScreenshotTest | |
|
||||
| AC-8.11 | Generated expense is ordinary; detail shows "Recurring" with template name | Done | ExpenseDetailScreen.kt:217, :238-239; ExpenseDetailViewModelTest.recurring_namesTheTemplate | |
|
||||
| §9.2 | Effect line Lent / Borrowed / Not involved / Paid for yourself | Partial | data Snapshot.kt:124-132 `effectFor`; core/design Money.kt:137-148 | Logic is correct by reading, but no `:data` unit test covers the four cases (screenshots use the fakes) |
|
||||
| CS-1 | Suggestion only for a new expense, never on edit | Done | ExpenseEditorViewModel.kt:217-218, :493; ExpenseEditorViewModelTest.editMode_loadsTheExpense_keepsTheCategory_andDeletes | |
|
||||
| CS-2 | Picking by hand stops suggestions for the session | Done | ExpenseEditorViewModel.kt:339; ExpenseEditorViewModelTest.suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault | |
|
||||
| CS-3 | Recompute on description and account change, local only | Done | ExpenseEditorViewModel.kt:326, :347, :491-502 | 150 ms debounce (not in spec, harmless) |
|
||||
| CS-4 | Normalisation (NFC, lowercase, delete ' ’ :, non-letter/digit/& to space) | Done | domain CategorySuggestion.kt:49; CategoryKeywordsTest.platformUnicodeText_nfc_letters_nd_digits_supplementary; VectorTest (suggest_category.json) | |
|
||||
| CS-5 | Order: exact history, first-token history, keywords, default; ties most recent then id | Done | domain CategorySuggestion.kt:74-122; CategorySuggestionTest.history_countsEveryAccountAndBothUsers_mostOftenWins_deletedLeftOut, history_tie_goesToTheMostRecentlyUsed, fallback_isTheCategoryLastUsedInTheAccount_elseOther | |
|
||||
| CS-6 | Keyword table token/prefix/phrase, longest wins, archived skipped | Done | CategorySuggestion.kt:134-143; CategorySuggestionTest.keywords_applyWithoutHistory_andNeedTheKeysCategory, archivedCategories_areSkipped_inHistoryAndKeywords; data CategoryKeywordsTest.bundledTableIsByteIdenticalToTheContractFile | |
|
||||
| CS-7 | No marker; clearing returns to step-4 default | Done | ExpenseEditorViewModelTest.suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault; ExpenseScreenshotTest | |
|
||||
| AC-10.1 | Home global balance wording from the viewer's side | Done | HomeViewModelTest.householdShowsBalanceAccountsAndFab, settledHasNoSettleButton; HomeScreenshotTest | |
|
||||
| AC-10.2 | Active accounts with balance; archived collapsed | Done | HouseholdRepositories.kt:100-101; HomeViewModelTest.archivedSectionToggles | |
|
||||
| AC-10.3 | Account screen: balance and expenses and settlements interleaved by date | Done | HouseholdRepositories.kt:157-166; RepositoriesTest.accountLedger_settlementRowCarriesTheAllocationToThatAccount; AccountViewModelTest.loadedAccountShowsBalanceMonthsAndFab | |
|
||||
| AC-10.4 | Balances on the phone, include pending, exclude deleted, update after local write | Done | RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones; domain Balances.kt; VectorTest (compute_balances.json) | |
|
||||
| AC-10.5 | Global = sum of per-account balances, archived included | Done | RepositoriesTest.home_balancesIncludePendingRows_andExcludeTombstones (assert "AC-10.5") | |
|
||||
| AC-11.1 | Settlement: amount, date, optional note, ≥1 allocations | Done | model SettleRequest; SettleViewModelTest.invalidNoteIsReportedAndClearedOnEdit | |
|
||||
| AC-11.2 | Allocations sum to amount, each positive, account at most once | Done | domain Allocation.kt; VectorTest (validate_settlement.json); SettleViewModelTest.removeAndAddAccountsUpdateTheRows | |
|
||||
| AC-11.3 | Global settle-up defaults, proposal rows, "Add account" (archived too), confirm only when sums match, live remaining | Done | data SettlementRepositoryImpl.kt:57-61; SettleViewModel.kt:81-84; RepositoriesTest.settleStart_theUserWhoOwesPays_amountIsTheBalance; SettleViewModelTest.globalDefaultsAreTheProposal, zeroBalanceGivesEmptyAmountAndNoRows | |
|
||||
| AC-11.4 | Per-account settle-up: one allocation, defaults to account balance and debtor | Done | SettlementRepositoryImpl.kt:59; SettleViewModelTest.perAccountHasOneFixedRowThatFollowsTheAmount | |
|
||||
| AC-11.5 | Over-settle allowed, warned per row, confirm dialog repeats | Done | SettleViewModel.kt:102, :194; SettleViewModelTest.overSettlingWarnsAndConfirmOpensTheDialog, saveWithoutWarningsIsDirect | |
|
||||
| AC-11.6 | Create/edit/delete offline; editing excludes itself; saved rows count as edited; "Suggest again" | Done | SettleViewModel.kt:157, :184; SettleViewModelTest.editingStartsFromTheSavedSettlement, offlineWorksUnchanged; RepositoriesTest.settleState_listsTheAccountsTheSettlementLeavesUntouched | |
|
||||
| AC-11.7 | Settlement row "<Payer> paid <Recipient> X", account count when > 1; allocation in S-5 | Done | core/design Ledger.kt:115-128; data Snapshot.kt:141; RepositoriesTest.accountLedger_settlementRowCarriesTheAllocationToThatAccount | |
|
||||
| AC-12.3 (phone) | Audit entries reach the phone through pull | Done | SyncEngineTest.pull_auditEntriesAreInsertOrIgnore | |
|
||||
| AC-12.4 | Detail timeline, newest last, field names in UI language, ids resolved to names | Done | data repo/Timeline.kt:31-69; core/design component/Timeline.kt:50-64, :76-93; RepositoriesTest.expenseDetail_timelineResolvesNames_andEndsWithThePendingLine | `splits`/`allocations` rendered as "name amount; …" (accepted in §14.2.4 v0.5) |
|
||||
| AC-12.5 | Pending local change ends the timeline with "Your change is waiting to sync" | Done | Timeline.kt (data):38-44; ExpenseDetailViewModelTest.loaded_withThePendingLineLast; SettlementDetailViewModelTest.pendingChangeEndsTheTimeline | |
|
||||
| §9.8 money | `1 234,50 kr` / `SEK 1,234.50`, no sign | Done | core/design format/MoneyFormatter.kt; MoneyFormatterTest.swedishUsesNoBreakSpacesAndCommaDecimal, otherLocalesPutSekFirstWithCommaThousands, neverShowsASign | |
|
||||
| §9.8 dates | List, detail, timeline, axis, day ranges | Done | DateFormatterTest.listDatesWithoutYearInTheCurrentYear, listDatesWithYearInOtherYears, detailDates, timesAre24HourInTheLocalZone, monthNamesAndLongDayRanges | |
|
||||
| §9.8 relative | "just now", "N min ago", "N h ago", else date and time | Done | DateFormatterTest.relativeLastSynced | |
|
||||
| §9.8 file sizes | SI MB one decimal half up | Done | MoneyFormatterTest.megabytesHaveOneDecimal | |
|
||||
| §9.9 | Online-only gate: validated network and 30 s after a network error; REST failure changes nothing locally | Done | data connectivity/Connectivity.kt; RepositoriesTest.onlineOnly_withoutValidatedNetwork_isOffline_andSendsNothing, onlineOnly_withinThirtySecondsOfANetworkError_isOffline, rest_networkFailure_changesNothingLocally; KvittApiTest.networkFailureIsNetworkError_andOpensTheThirtySecondWindow | Per-screen gating of S-13 to S-22 is fork C's |
|
||||
| S-1 Pair | Pair with URL, code, name; progress; errors invite_invalid, network, rate_limited | Done | PairingViewModelTest.errorsMapToTheirFields, whilePairingTheButtonIsBusyAndEditingClearsTheFieldError, offlinePairingGivesTheNetworkErrorUnderTheExpandedServer; OnboardingScreenshotTest | |
|
||||
| S-1 Paste code | Paste fills code (and server from a pasted link) | Done | PairingViewModelTest.pasteFillsCodeAndServerOrSaysTheCodeHasTwelveCharacters | |
|
||||
| S-2 Continue | Name prefilled, blank shows error, "Add picture", "You are joining <owner>" | Done | KvittApp.kt:223-225; OtherViewModelsTest.theProfileStepPrefillsTheNameAndNamesTheOwner, continueSavesAChangedNameAndStaysOnFailure, anUploadDisablesContinueUntilItEndsAndFailuresGiveTheirReason | |
|
||||
| S-3 Invite partner | Goes to S-21 | Done | KvittApp.kt:239 | |
|
||||
| S-3 Settings | Bottom bar to Settings | Done | WaitingScreen.kt:100 (`KvittNavigationBar`), KvittApp.kt:239 `onNavigate = shell::top` | |
|
||||
| S-3 must show | Expiry of pending invite; error snackbar with retry | Done | WaitingScreen.kt:91-93, :150-168; OtherViewModelsTest.aFailedInviteListIsAnErrorAndComingOnlineLoadsAgain | |
|
||||
| S-4 Add expense (FAB) | FAB opens S-7 | Done | KvittApp.kt:242; HomeViewModelTest.emptyAndOnlyArchivedHideTheFab | |
|
||||
| S-4 Settle up | Button when non-zero opens S-9 | Done | KvittApp.kt:243; HomeViewModel.kt:53 | |
|
||||
| S-4 open account | Row opens S-5 | Done | KvittApp.kt:244 | |
|
||||
| S-4 New account | Opens S-6, then S-5 of the new account | Done | KvittApp.kt:245, :301-304 | |
|
||||
| S-4 must show | Sync line (tap S-16), update banner with Later, expenses this month, empty state | Done | KvittApp.kt:246; HomeViewModel.kt:96-124; HouseholdRepositories.kt:93; HomeViewModelTest.offlineAndPendingAreReported, laterHidesTheBanner, firstUpdateOpensTheDialogAndContinueInstalls; HomeScreenshotTest | |
|
||||
| S-5 Add expense | FAB (not on archived) opens S-7 in this account | Done | KvittApp.kt:286; AccountViewModelTest.archivedAccountHidesTheFab; ExpenseEditorViewModelTest.openedFromAnAccount_startsInThatAccount | |
|
||||
| S-5 Settle this account | Opens S-9 per-account | Done | KvittApp.kt:287 | |
|
||||
| S-5 Edit account | Opens S-6 | Done | KvittApp.kt:288 | |
|
||||
| S-5 Archive / Unarchive | With undo snackbar on Home after archive | Done | KvittApp.kt:289-293; AppViewModel.kt:73-75; AccountViewModelTest.archiveNavigatesBackWithTheName, unarchiveStaysWithASnackbar | |
|
||||
| S-5 Delete (only if allowed) | Shown only when deletable, confirmation | Done | AccountDetailScreen.kt:160; KvittApp.kt:294; AccountViewModelTest.emptyAccountMayBeDeletedOnlyOnline, deleteAsksThenDeletes | |
|
||||
| S-6 Save | Name, split control, preview "Of 100,00 kr …", needs connection | Done | AccountEditorScreen.kt:171, :273-282; KvittApp.kt:301-304; AccountViewModelTest.createSavesBasisPointsAndReportsTheNewId | |
|
||||
| S-6 Cancel | Discard dialog title only | Done | AccountEditorScreen.kt:94, :132-137; AccountViewModelTest.closingWithChangesAsksFirst | |
|
||||
| S-7 Save | Saves and shows "Utgiften sparades" on the screen returned to | Done | KvittApp.kt:311-314; ExpenseEditorViewModelTest.tenSecondPath_amountDescriptionSuggestionSave, editSave_reportsAnEdit | |
|
||||
| S-7 Cancel | Discard dialog only with changes | Done | KvittApp.kt:310; ExpenseEditorViewModelTest.close_asksFirstOnlyWithChanges_andBackHidesTheKeypadFirst | |
|
||||
| S-7 Delete (edit mode) | Delete, closes detail too, snackbar | Done | KvittApp.kt:315-319; ExpenseEditorViewModelTest.editMode_loadsTheExpense_keepsTheCategory_andDeletes | |
|
||||
| S-7 must show | Four fixed rows, quick row, notes collapsed, live preview | Done | EditorOptionRowsLayoutTest.rowsStayPutWhenValuesChange; ExpenseEditorViewModelTest.quickRow_…; ExpenseEditorScreen.kt:265-266; ExpenseScreenshotTest | |
|
||||
| S-8 Edit | Opens S-7 in edit mode | Done | ExpenseDetailScreen.kt:130-131; KvittApp.kt:325 | |
|
||||
| S-8 Delete | Confirmation, back, snackbar | Done | ExpenseDetailScreen.kt:132; KvittApp.kt:326-329; ExpenseDetailViewModelTest.delete_asksThenDeletes | |
|
||||
| S-8 must show | Deleted state; missing state with "Synka nu" online only | Done | ExpenseDetailScreen.kt:143-170, :244-257; ExpenseDetailViewModelTest.missing_isNullAfterLoading, deleted_staysVisibleAsDeleted | |
|
||||
| S-9 Confirm | Saves; with warnings via dialog; snackbar on return | Done | KvittApp.kt:336-339; SettleViewModelTest.overSettlingWarnsAndConfirmOpensTheDialog, saveWithoutWarningsIsDirect | |
|
||||
| S-9 Cancel | Discard dialog only with changes | Done | KvittApp.kt:335; SettleViewModelTest.closeAsksOnlyWithChanges | |
|
||||
| S-9 must show | Direction swap, remaining, add account, date, note, untouched info line | Done | SettleViewModel.kt:163, :175; SettleViewModelTest.swapReproposesForTheOtherPayer; SettleScreenshotTest | |
|
||||
| S-10 Edit | Opens S-9 editing | Done | KvittApp.kt:345 | |
|
||||
| S-10 Delete | Confirmation, back, snackbar | Done | SettlementDetailScreen.kt:110, :139-147; KvittApp.kt:346-349; SettlementDetailViewModelTest.deleteAsksThenDeletes | |
|
||||
| S-10 must show | Missing state with "Synka nu" online only; deleted state | Done | SettlementDetailViewModel.kt:46-49, :97; SettlementDetailViewModelTest.missingOfflineCarriesTheConnectivityForSyncNow, deletedHasTheDeletionLineAndNoActions | |
|
||||
|
||||
|
||||
### Part C: Android recurring, push, analytics, sync, update, localisation; S-11 to S-22; UI contract; TODO scan
|
||||
|
||||
Audited at `ed1b130`, read-only. Paths are relative to `android/` unless they start with `server/`. "fork A" means the server-side rows are in part A.
|
||||
|
||||
#### (1) Table
|
||||
|
||||
| id | requirement (one line) | status | evidence (file:line or test name) | note |
|
||||
|---|---|---|---|---|
|
||||
| AC-9.1 | Create, edit, pause, resume and delete a template (online), with every field | Done | `data/.../repo/SettingsRepositories.kt:135-185` (E-28/E-29/E-30 via `OnlineGate`); `feature/settings/.../recurring/RecurringEditorViewModel.kt:187,221,250`; StateHolderTest `newTemplateDefaultsAndSave`, `editTemplateLoadsSplitAndMarksServerErrors` | Pause and resume are the editor's pause switch, saved with E-29 |
|
||||
| AC-9.4 | Editing a template changes only expenses generated later | Done | server test `generation.rs::archived_account_continues_and_edits_affect_only_later_expenses` | Server-side rule; the phone only sends E-29 |
|
||||
| AC-9.7 | List shows description, amount, account, day of month, paused state, next occurrence (server `next_occurrence_date`) | Done | `feature/settings/.../recurring/RecurringListScreen.kt:119-153`; `SettingsRepositories.kt:245` (`nextOccurrence = row.nextOccurrenceDate`); RecurringScreenshotTest | |
|
||||
| AC-9.8 | Default `start_period` (current month if the day is today or later, else next); new day = today; backfill up to 12 months | Done | `feature/settings/.../recurring/Periods.kt:24-33`; `RecurringForm.kt:136,200`; PureRulesTest `periods`, `templateValidation` | |
|
||||
| AC-13.5 | Notification only after its sync; text from local data in the phone's language; generic text when the sync fails within 20 s | Done | `app/.../push/PushWork.kt:25-30`; `data/.../sync/SyncTriggers.kt:70-75` (20 000 ms); `app/.../push/Notifications.kt:50-57`; NotificationTextsTest (`failedSyncGivesTheGenericTextWithoutContent`, `missingEntityGivesTheGenericText`); SyncTriggersTest `pushWaitGivesUpAfterItsTimeout` | Real delivery was seen in the M8 phone test (HANDOFF) |
|
||||
| AC-13.6 | A tap opens S-8 or S-10; Home for deletions, `sync_batch` and the generic text | Done | `app/.../push/NotificationTexts.kt` (targets); `nav/KvittApp.kt:136-144`; NotificationTextsTest `expenseDeletedOpensHome`, `settlementDeletedOpensHome`, `syncBatchCountsAndOpensHome`; AppLinksTest | |
|
||||
| AC-13.7 | Register by FID when paired and at each start; send every FID with E-17; unregister on revoke | Done | `app/.../push/PushHandler.kt:37,52-62,68-76`; `adapter/fcm/.../KvittMessagingService.kt:22-23` | The M8 phone test confirmed `fcm_token` was registered. The FID is sent only while PAIRED, and is not retried after a network failure until Firebase calls `onRegistered` again |
|
||||
| §14.6-1 | Phone step 1: `syncForPush`, waiting up to 20 s, in expedited work | Done | `PushWork.kt:40-54` (`setExpedited`); `SyncTriggers.kt:70-75` | |
|
||||
| §14.6-2 | Channel `activity` ("Activity"/"Aktivitet", importance default); id = hash of `entity_id`; `sync_batch` uses id 1 | Done | `Notifications.kt:27-35`; `KvittApplication.kt:34`; `NotificationTexts.kt` `NotificationIds` (SYNC_BATCH = 1, `entityId.hashCode()`); NotificationTextsTest `updateReplacesCreate`, `syncBatchIsFixed` | |
|
||||
| §14.6-3 | Tap targets as in AC-13.6 | Done | see AC-13.6 | |
|
||||
| §14.6-4 | An unknown `type` or a `v` other than 1 syncs only, with no notification | Done | `adapter/fcm/.../PushMapping.kt:19-21`; `PushHandler.kt:32-35`; PushMappingTest `otherPayloadVersionsAreSyncOnly`, `unknownTypeIsKeptForTheAppToIgnore`; NotificationTextsTest `unknownTypeShowsNothing` | |
|
||||
| AC-14.1 | Analytics are computed on the phone from Room, offline, with pending changes | Done | `data/.../repo/AnalyticsRepositoryImpl.kt:33-55`; RepositoriesTest `analytics_includePending_excludeDeleted`; AnalyticsViewModelTest `offlineWorksAndCountsPendingChanges` | |
|
||||
| AC-14.2 | Range presets and custom; "last N months" is whole months; default `last_6_months` | Done | `domain/.../Analytics.kt:82-104`; vectors `contracts/test-vectors/resolve_range.json` (VectorTest); ResolveRangeTest; AnalyticsRulesTest `defaultsAreThoseOfAc14` | |
|
||||
| AC-14.3 | Granularity month or ISO week (Monday start); default month | Done | `Analytics.kt:36-47,108-113`; spend_series vectors | |
|
||||
| AC-14.4 | Measure household or my share; default household | Done | `Analytics.kt:53-56`; `AnalyticsRepositoryImpl.kt:95-96` | |
|
||||
| AC-14.5 | Group by category or account; default category | Done | `Analytics.kt:57-60`; AnalyticsRulesTest `defaultsAreThoseOfAc14` | |
|
||||
| AC-14.6 | Chart 1 stacked bars; chart 2 by category; chart 3 by account (sorted descending, with amount and percent); total above; settlements never counted | Done | `feature/analytics/.../AnalyticsScreen.kt:172,188-191,217-225`; `AnalyticsRepositoryImpl.kt:43-44` (sorted descending); `Analytics.kt` reads expenses only | |
|
||||
| AC-14.7 | Every bucket appears, empty ones as zero; edge buckets clipped to the range | Done | `Analytics.kt:35-48,65-69`; spend_series vectors (13 cases) | |
|
||||
| AC-14.8 | Tapping a bar or a row opens the expenses behind it | Done | `AnalyticsScreen.kt:111`; `KvittApp.kt` `Routes.DRILL_DOWN`; `AnalyticsRepositoryImpl.kt:57-86`; DrillDownViewModelTest | |
|
||||
| AC-14.9 | Deleted expenses excluded; archived accounts and categories included | Done | `AnalyticsRepositoryImpl.kt:91-93` (`liveExpenses`); RepositoriesTest `analytics_includePending_excludeDeleted` | |
|
||||
| AC-14.10 | Percent rounded half-up to one decimal | Done | `core/design/.../chart/Charts.kt:255-267` (`MoneyFormatter.percent`); MoneyFormatterTest `percentIsHalfUpToOneDecimal` | |
|
||||
| AC-15.1 | Full local copy of users, accounts, categories, templates, expenses, settlements, audit | Done | `data/.../db/Entities.kt`, `KvittDatabase.kt`; SyncEngineTest `pull_*` | |
|
||||
| AC-15.2 | Expenses and settlements work offline; other actions are disabled offline with "Needs connection" | Done | `data/.../connectivity/Connectivity.kt` (`OnlineGate`); RepositoriesTest `onlineOnly_withoutValidatedNetwork_isOffline_andSendsNothing`, `onlineOnly_withinThirtySecondsOfANetworkError_isOffline`; `NeedsConnectionHint` in CategoryListScreen, SyncStatusScreen; StateHolderTest `profileOfflineDisablesEverything` | |
|
||||
| AC-15.3 | Sync on start, on foreground, 2 s after a write, every 15 min (network constraint), and on FCM (expedited); one at a time; names `kvitt-sync` APPEND_OR_REPLACE and `kvitt-sync-periodic` KEEP | Done | `SyncTriggers.kt:49-64,90-115`; `app/.../AppLifecycle.kt:37-50`; `SyncEngine.kt:55` (Mutex); SyncTriggersTest (4 tests), SyncEngineTest `oneCycleAtATime` | The WorkManager wiring itself is not unit-tested (a fake `SyncWork` is used) |
|
||||
| AC-15.4 | Home shows "Last synced" and the pending count; sync status screen | Done | `core/design/.../component/Sync.kt` (`SyncStatusPill`); `feature/home/.../SyncStatusScreen.kt`; HomeScreenshotTest | |
|
||||
| AC-15.5 | LWW per row, deletes final, rejected changes shown | Done | `SyncEngine.kt` (applyResults); SyncEngineTest `push_rejectedStale_*`, `push_rejectedWithoutCurrent_*`; RepositoriesTest `syncStatus_listsPendingRowsAndRejections_andDismisses` | |
|
||||
| AC-15.6 | 14 days offline with 200 changes syncs everything (scripted clock) | Done | `data/src/test/.../sync/OfflineFourteenDaysTest.kt:44` `ac15_6_fourteenDaysOffline_twoHundredChanges_allSyncOnReconnect` | |
|
||||
| §14.4.5-1 | Pending rows by `local_updated_at`, up to 100, `mutation_id = pending_mutation_id` | Done | `SyncEngine.kt:163-175,390`; SyncEngineTest `push_batchesOf100_oldestLocalChangeFirst_withExactlyTheClientWritableFields`, `push_mutationIdIsThePendingMutationId` | |
|
||||
| §14.4.5-2 | Network error or 5xx: retry with backoff; 401 revoked/unauthorized; 429 retry; 426 stop; whole-request refusal goes on to pull and records `last_error_code` | Done | `SyncEngine.kt:125-160`; SyncEngineTest `push_networkError_*`, `push_serverError_asksForRetry`, `deviceRevoked_wipesTokenAndLocalData`, `unauthorized_keepsData_*`, `protocolUnsupported_stopsSyncingUntilUpdated` | No test covers the 429 path or the D-040 whole-request refusal (400/413/409/422) |
|
||||
| §14.4.5-3 | Apply accepted results, rejections with equal content, other rejections, and `clock_skew` with the D-094 re-stamp | Done | SyncEngineTest `push_accepted_*` (2), `push_rejectedWithEqualContent_isTreatedAsAccepted`, `push_rejected_whileEditedAgain_*`, `push_clockSkew_*`, `push_afterClockSkew_restampsRowsStampedAhead_*`, `push_withoutAClockSkewFlag_neverRestamps` | |
|
||||
| §14.4.8 | Epoch change and `cursor_ahead` lead to recovery; on 401 Unrecognised the data is kept; a re-pair by the same user keeps the data and re-pushes, another user wipes | Done | `SyncEngine.kt:314`; `data/.../repo/SessionRepositoryImpl.kt:66-72`; SyncEngineTest `epochChange_*`, `cursorAhead_recoversLikeANewEpoch`; SessionRepositoryTest | |
|
||||
| AC-16.1 | Server hosts the latest APK and metadata | Done | `server/crates/kvitt-server/src/api/apk.rs`; server test `core_api/apk.rs` | Server side, see fork A |
|
||||
| AC-16.2 | Release builds check E-5 on cold start and at most every 6 h while resumed; banner with version name | Done | `AppLifecycle.kt:44-47`; `data/.../update/Updater.kt:84-113,166`; UpdaterTest `checksAtMostEverySixHours`, `newerVersionIsAvailable_sameIsNone`; `feature/home/.../HomeScreen.kt:173,240` | |
|
||||
| AC-16.3 | Download to cache, verify size and SHA-256, install via PackageInstaller; `NeedsInstallPermission` emitted once | Done | `Updater.kt:39-57,117-144,177-196`; UpdaterTest `verifiedDownloadIsInstalled`, `theInstallPermissionIsExplainedOnceBeforeTheFirstInstall`; `HomeScreen.kt:276-287` | The installer is behind a fake in tests |
|
||||
| AC-16.3-device | The real PackageInstaller session and the system "Install unknown apps" prompt | Not verifiable without device | `Updater.kt:177-209` | Needs a signed release APK published on a server (M10) |
|
||||
| AC-16.4 | A verification failure deletes the file and shows "Download failed, try again" | Done | `Updater.kt:137-141`; UpdaterTest `shaMismatchDeletesTheFile_andFails`, `sizeMismatchDeletesTheFile_andFails`; golden S-17 failed | |
|
||||
| AC-16.5 | Debug builds never check | Done | `Updater.kt:85`; `AppLifecycle.kt:46`; UpdaterTest `debugBuildsNeverCheck` | |
|
||||
| AC-16.6 | "Check for update" gives UpToDate, Failed or Available, with copy; debug gives UpToDate without a request | Done | `feature/settings/.../about/AboutViewModel.kt:43-59`; UpdaterTest `checkResult_tellsUpToDateFromAFailure` | In debug builds the button is hidden (`MainActivity.kt:35` `updateCheckAvailable = !BuildConfig.DEBUG`) rather than reporting UpToDate. This is consistent in effect |
|
||||
| AC-19.1 | Every string is a resource in en and sv; no literal in feature code; the gate fails on a missing key | Done | `build-logic/.../AndroidCommon.kt:14-16` (`MissingTranslation` as error, `abortOnError`); StringsCatalogueTest; OnboardingStringsTest | Nothing in the gate stops a Kotlin string literal in a feature. A grep for `Text("`, `text = "` and `contentDescription = "` in `feature/*/src/main` found none |
|
||||
| AC-19.2 | Money and dates formatted per §9.8 | Done | MoneyFormatterTest, DateFormatterTest (`core/design/src/test/...`) | |
|
||||
| AC-19.3 | Default category names and user-visible enums exist in both languages | Done | StringsCatalogueTest `theFourteenDefaultCategoryNamesAndTheNotificationCopyArePresent`; lint `MissingTranslation` | |
|
||||
| S-11 | Change filters (range, granularity, measure, group by, custom) | Done | `AnalyticsViewModel.kt`; AnalyticsViewModelTest `togglesReQueryAtOnce`, `customOpensThePickerAndAppliesOnlyWithBothDates` | |
|
||||
| S-11 | Tap a bar or a row to open S-12 | Done | `AnalyticsScreen.kt:111`; `KvittApp.kt` ANALYTICS to DRILL_DOWN | |
|
||||
| S-11 must-show | Total card, legend, empty state, skeleton loading, no error state | Done | `AnalyticsScreen.kt:172-225`; AnalyticsViewModelTest `firstSyncIsLoading`; AnalyticsRulesTest `loadingOnlyBeforeTheFirstSync` | |
|
||||
| S-12 | Open expense | Done | `DrillDownScreen.kt:61-68`; `KvittApp.kt` DRILL_DOWN `onOpenExpense` to Routes.expense | |
|
||||
| S-13 | Navigate (Profile, Categories, Recurring, Devices, Invite or Pair (admin), Sync status, About) | Done | `feature/settings/.../hub/SettingsHubScreen.kt:98-118`; `SettingsHubViewModel.kt:45-49`; `KvittApp.kt` SETTINGS block; StateHolderTest `hubCountsAndRows` | |
|
||||
| S-14 | Save (name), change or remove the picture (photo picker), offline state | Done | `profile/ProfileScreen.kt:59-75,117`; `SettingsRepositories.kt:77-83`; StateHolderTest `profileNameRulesAndSave`, `profileOfflineDisablesEverything` | |
|
||||
| S-15 | Add | Done | `KvittApp.kt` CATEGORIES `onNewCategory`; StateHolderTest `newCategoryNeedsAName` | |
|
||||
| S-15 | Edit (name with "Reset name", 33 icons, 14 swatches) | Done | `categories/CategoryEditorScreen.kt`; StateHolderTest `categoryResetNameSavesNull`; `SettingsRepositories.kt:105-106` | |
|
||||
| S-15 | Reorder (drag) | Done | `categories/CategoryListScreen.kt:98-101,164-179` (long press and handle drag, plus move up/down); `SettingsRepositories.kt:108-119` (one PATCH per changed `sort_order`); StateHolderTest `categoryReorderFailureRestoresTheOrder` | No `:data` test covers `CategoryRepositoryImpl.reorder` |
|
||||
| S-15 | Archive | Done | `CategoryEditorViewModel.kt`; StateHolderTest `archiveReturnsWithASnackbarForTheList` | |
|
||||
| S-15 | Delete, with the reason when not allowed | Done | `CategoryEditorViewModel.kt:29-42`; StateHolderTest `categoryDeleteAffordanceFollowsDeletable` | |
|
||||
| S-16 | Sync now (online only) | Done | `feature/home/.../SyncStatusViewModel.kt:35-36,51-52`; `SettingsRepositories.kt:305-307` | |
|
||||
| S-16 | Open a rejected change (read-only) | Partial | `SyncStatusScreen.kt` RejectionCard (around line 274): opens S-8/S-10 for `rejection.entityId` | Opens the normal editable detail of the current local row, which holds the server's winning version (or "missing" when `current` was null). The lost payload in `sync_rejections.rejected_payload` is never shown, so "what was lost" is limited to the summary line |
|
||||
| S-16 | Dismiss it | Done | `SyncStatusScreen.kt` (around line 298) dismiss button; `SyncStatusViewModel.kt:56-57`; RepositoriesTest `syncStatus_listsPendingRowsAndRejections_andDismisses` | |
|
||||
| S-16 must-show | Clock-skew warning, "Sync failed", stale copy naming `Rejection.actor` | Done | `SyncStatusScreen.kt:141`; `SyncStatusViewModel.kt:29`; RepositoriesTest `syncStatus_aRejectionNamesWhoMadeTheServersVersion` | |
|
||||
| S-17 | Update | Done | `HomeScreen.kt:115-119,240-241`; `AboutViewModel.kt:61` | |
|
||||
| S-17 | Later (hides the banner for the ViewModel's lifetime) | Done | `HomeViewModel.kt:68,88` | |
|
||||
| S-17 must-show | Version name, "Install unknown apps" dialog once, progress, failure | Done | `HomeScreen.kt:154,276-287`; goldens S-17 update, progress and failed | |
|
||||
| S-18 | Pair again (goes to S-1); back closes the app | Done | `KvittApp.kt` REMOVED (`BackHandler { activity?.finish() }`, `replaceAll(Routes.PAIRING)`) | |
|
||||
| S-19 | Add | Done | `KvittApp.kt` RECURRING `onNewTemplate`; StateHolderTest `newTemplateDefaultsAndSave` | |
|
||||
| S-19 | Edit | Done | `KvittApp.kt` TEMPLATE_EDITOR; StateHolderTest `editTemplateLoadsSplitAndMarksServerErrors` | |
|
||||
| S-19 | Pause/Resume | Done | `RecurringEditorViewModel.kt:70,187` (switch plus resume hint) | Only from inside the editor; the list has no direct pause action. The spec does not require one |
|
||||
| S-19 | Delete | Done | `RecurringEditorViewModel.kt:221,250-254` | |
|
||||
| S-20 | Revoke (with confirmation) | Done | `devices/DevicesViewModel.kt:89-103`; `canRevoke` (AC-3.2); StateHolderTest `devicesLoadFailAndRevoke`; PureRulesTest `revokeRules` | Revoked devices are filtered out of the list (`DevicesViewModel.kt:76`) |
|
||||
| S-21 | Create (partner before the join; device for either user) | Done | `invites/InvitesViewModel.kt` `invitePartner`, `inviteDevice`; `InvitesScreen.kt:185-186`; StateHolderTest `invitesCreateAndRefuse` | Admin only (`SettingsHubViewModel.kt:45-49`), as E-12 requires |
|
||||
| S-21 | Share (Android share sheet) | Done | `InvitesScreen.kt:80-91` (`ACTION_SEND`, `createChooser`) | |
|
||||
| S-21 | Cancel a pending invite | Done | `InvitesViewModel.kt` `askCancel`, `cancel` | |
|
||||
| S-21 must-show | Code in groups of 4, link, expiry | Done | `InvitesScreen.kt:192-214` | |
|
||||
| S-22 | Check for update | Done | `about/AboutScreen.kt:106-117`; `AboutViewModel.kt:43-59` | Hidden in debug builds |
|
||||
| S-22 must-show | Version name and code, server URL, server version "(from /health)" | Partial | `data/.../repo/SettingsRepositories.kt:336-345`; `data/.../api/KvittApi.kt:241` (`X-Kvitt-Server-Version`) | The server version comes from the `X-Kvitt-Server-Version` response header of the last request, not from E-1 `/health` as §13.3 states. It is null until a request reaches the server. The value is the same crate version |
|
||||
| §14.2.4 | UI contract types and interfaces in `:core:model` match the spec block | Done | `core/model/src/main/kotlin/se/hagstedt/kvitt/model/*.kt` (Ids, Enums, Records, Session, Home, Ledger, Accounts, Expenses, Settlements, Analytics, Settings, Sync, Push); `core/model/src/test/.../ContractTest.kt` | Every type, member, parameter and enum order checked matches, including the v0.13 additions (Pairing, lastAttemptFailed, Rejection.actor, appVersionName, NewRecurringTemplate, UpdateCheck, untouched, PushEvent). No differences found |
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
> Taken at `18c527f` (main, 2026-10-18, SPEC v0.17); committed verbatim with SPEC v0.18. Status of each finding: SPEC §11.4.
|
||||
|
||||
# Security review of Kvitt at `18c527f` (2026-10-18)
|
||||
|
||||
Read-only review of the main checkout against SPEC v0.17 §8, §10, §14.3, §14.4, §14.6 and general practice. Scope: `server/crates/**`, `android/**`, `infra/**`, `contracts/openapi.yaml`, `.gitea/workflows/**`. No build, no phone, no network, no repository change. Threat model as in SPEC §8.1: one public port (8443) on a home connection, two trusted users, attacker is an internet stranger or a lost phone. Aggregates only; no secret values were read.
|
||||
|
||||
## 1. Verdict
|
||||
|
||||
**No critical finding. One High: the in-memory rate limiter can be made to grow without bound and to bypass the per-IP limit by an unauthenticated stranger (DoS of the Pi). Fix it before port 8443 is opened.** Everything else is Medium or below, mostly hardening and owner decisions. The design (hashed 256-bit device tokens, 60-bit single-use invite codes with a 10-per-hour failure lockout, Keystore-sealed token on the phone, no backup/extraction, one exported activity, parameterised SQL everywhere, path-safe APK and picture files, read-only non-root server container behind Caddy) matches the spec and is sound.
|
||||
|
||||
Counts: Critical 0, High 1, Medium 3, Low 10, Info 8.
|
||||
|
||||
## 2. Findings
|
||||
|
||||
### High
|
||||
|
||||
**H-1. Rate limiter: a random bearer token becomes "accepted" on any non-401 response, bypasses the IP limit and is never pruned.**
|
||||
Location: `server/crates/kvitt-server/src/api/rate_limit.rs:273-287` (admit), `:305-314` (record), `:143-144` (prune), with `src/router.rs:102` (`require_protocol` runs before the token check) and `src/auth.rs:65-76`.
|
||||
Issue: for an authenticated route the limiter keys by the hash of any token-shaped bearer (`Key::Token`). `admit` only *checks* the IP window for a not-yet-accepted token, it does not count against it; `record` counts the IP only when the response is 401, and otherwise marks the token `accepted = true`. A request with a random `kvt_` token and **no `X-Kvitt-Protocol` header** gets 426 from `require_protocol` (before the database is touched), so the random token is marked accepted; `prune` keeps accepted tokens forever. The same happens with 405 (wrong method on a known path).
|
||||
Exploit (internet stranger, no credentials): send `GET /me` with a fresh random token and no protocol header in a loop. No IP counter is ever incremented, so there is no rate limit at all on this path, and every request adds a permanent `HashMap` entry (about 150 bytes) to a container limited to 160 MiB: a few hundred thousand requests, well within an hour on a home uplink, exhaust the server's memory (OOM kill, restart loop). Secondary effect: each such "accepted" token then gets its own 600/min bucket of database lookups on later requests with the protocol header, so the per-IP 60/min limit of §8.6 no longer bounds the database load from one IP.
|
||||
Smallest fix (server): in `record`, mark a token accepted only when `status.is_success()` (2xx), not on every non-401/429; in `admit`, for a not-yet-accepted token *hit* the IP window (count it) instead of only checking it; in `prune`, also drop accepted tokens whose window has expired (re-marking costs one successful request). Add a test: random token + no protocol header × N leaves `tokens.len()` bounded and returns 429 after 60.
|
||||
|
||||
### Medium
|
||||
|
||||
**M-1. Backups on Google Drive are unencrypted and the rclone token has full-Drive scope.**
|
||||
Location: `infra/backup/backup.sh:55-72`; SPEC §8.7 last bullet vs §8.1 ("a compromised backup location" is in scope); SPEC §10.9 (`scope = drive`).
|
||||
Issue: the daily `kvitt-*.sqlite.gz` (all expenses, notes, names) and pictures go to Drive in clear; the mounted `rclone.conf` carries an OAuth refresh token with the `drive` scope, i.e. read/write to the owner's entire Drive, not just the backup folder. A compromise of the backup container or of `/opt/kvitt/secrets/rclone.conf` exposes the whole Drive; a compromise of the Google account exposes every backup. The spec accepts the first half (§8.7) while listing the second half as a threat (§8.1).
|
||||
Smallest fix (infra + owner decision): wrap the remote in an rclone `crypt` remote (`[gdrive-crypt] type = crypt, remote = gdrive:<folder>`; the two passwords live in the already-secret `rclone.conf`, and `RESTORE.md` gains one `rclone copy` through the crypt remote); re-authorise with scope `drive.file` so the token reaches only files rclone created. Needs a decision entry and a §8.7/§10.9 edit.
|
||||
|
||||
**M-2. The cert-sync SSH key may be a root login on the Home Assistant Pi.**
|
||||
Location: `infra/cert-sync/sync.sh:36-42`; SPEC §10.5.1 ("variant B: added as a plain key"), `.env.example` ("the Home Assistant SSH add-ons log in as root").
|
||||
Issue: cert-sync holds `/secrets/ha_ssh_key`. With variant B there is no `restrict,command=` on the HA side, so whoever holds the key (a compromised cert-sync container, or the key copied around on the Mac and the Pi) gets an unrestricted root shell on the Home Assistant Pi, which also owns ports 80/443 and the house's automation. The script's own fixed `tar` command does not restrict the server side.
|
||||
Smallest fix (infra + owner): make variant A the only accepted variant (`restrict,command="tar -C <dir> -cf - fullchain.pem privkey.pem"`, plus `from="<Kvitt Pi LAN address>"`), and if the add-on cannot do that, use a dedicated non-root user that can read `/ssl` only. Record the variant in `infra/README.md` as §10.5.1 already asks.
|
||||
|
||||
**M-3. Picture decoding can exceed the server's memory limit (8000 × 8000 px vs 160 MiB).**
|
||||
Location: `server/crates/kvitt-server/src/api/pictures.rs:39` (`MAX_DIMENSION`), `:66-92` (`process`), `infra/pi/compose.yaml` (`mem_limit: 160m`); limit from SPEC AC-4.4.
|
||||
Issue: the dimension check happens before decoding (good), but an 8000 × 8000 image is allowed: decoding to RGBA is 256 MB, plus `to_rgba8`/`to_rgb8` copies, on a container limited to 160 MiB. A 5 MiB PNG or WebP easily encodes that size (a flat colour compresses to a few KB). Only an authenticated device can call `PUT /me/picture`, so in this threat model the attacker is a lost phone with a live token (until revoked) or a bug in the app, not a stranger; the effect is a crash and restart of the server (and of the sync for both phones) on each upload.
|
||||
Smallest fix (server, plus a spec number): decode through `image::ImageDecoder` limits (`Limits { max_image_width/height, max_alloc ~ 64 MiB }`) or lower the limit to a pixel count (for example 16 M pixels, 4000 × 4000) and refuse larger with `image_invalid`. The number is a spec value: `TODO(spec)` / decision entry.
|
||||
|
||||
### Low
|
||||
|
||||
**L-1. Caddy's access log records invite codes and the admin's display name.**
|
||||
Location: `infra/pi/Caddyfile:11-14`; SPEC §8.7 (the Rust server logs the route template, by design, `src/router.rs:134-142`).
|
||||
Issue: Caddy logs `request.uri` in full: `/i/<CODE>?from=<name>` and the `?from=` value, together with the client IP and User-Agent, into the json-file logs on the Pi (10 MB × 3). Codes live 72 h and are single-use, so the risk is low (an attacker with Pi log access is out of scope), but it is personal data and a secret in a log that §8.7 says must not hold either.
|
||||
Fix (infra): add `log_skip` (Caddy ≥ 2.8) for the `/i/*` matcher, or `log { format filter { request>uri delete } ... }` — one line in the Caddyfile; or drop the access log entirely (the server already logs every request).
|
||||
|
||||
**L-2. The Caddy container is not hardened like the other three.**
|
||||
Location: `infra/pi/compose.yaml` (`caddy` service: no `user`, no `cap_drop`, no `read_only`); SPEC §8.9 hardens only the server.
|
||||
Issue: Caddy runs as root with every default capability and a writable root filesystem, and its admin API is bound to `0.0.0.0:2019` for every container on the `kvitt` network (only cert-sync needs it). This is the process that terminates TLS from the internet.
|
||||
Fix (infra): `cap_drop: [ALL]`, `cap_add: [NET_BIND_SERVICE]` (or none: 8443 is unprivileged), `read_only: true` with `tmpfs: [/tmp]` and `caddy_data`/a `caddy_config` volume writable; keep `no-new-privileges`. Admin on `0.0.0.0` is required for cert-sync; acceptable on the internal network.
|
||||
|
||||
**L-3. APK download is unauthenticated and 11 MB per request at 60 requests per minute per IP.**
|
||||
Location: `server/crates/kvitt-server/src/api/apk.rs:76-101`, `src/api/rate_limit.rs:56-63`; SPEC §8.6, §10.6.
|
||||
Issue: the only large unauthenticated response. One IP may pull about 660 MB/min, several IPs more, saturating the home uplink (and the Pi's SD card) for as long as they like. The file is public by design (the landing page links it).
|
||||
Fix (server; the number is a spec value): a separate, lower window for `/apk/files/{file_name}` (for example 5 per minute per IP) — `TODO(spec)`.
|
||||
|
||||
**L-4. `fcm_token` has no length limit.**
|
||||
Location: `server/crates/kvitt-server/src/api/devices.rs:95-117`; SPEC §9.6 has no limit for it.
|
||||
Issue: an authenticated device can store a string of up to 1 MiB, which is then sent to FCM in every push body for that device (and kept in the database and backups). Trusted users only; a Firebase Installation ID is 22 characters.
|
||||
Fix (server, spec number): refuse more than, say, 256 characters with `validation_failed` (`TODO(spec)`).
|
||||
|
||||
**L-5. The development stack mounts every secret into the server container.**
|
||||
Location: `infra/dev/compose.yaml` (`${KVITT_SECRETS_DIR:-../secrets}:/secrets:ro`); SPEC §10 (accepted as the dev layout).
|
||||
Issue: the dev server container sees the HA SSH key, `rclone.conf`, the Gitea push key and `google-services.json`, although it needs only `fcm-service-account.json`. The Pi stack mounts the single file (correct).
|
||||
Fix (infra): mount `${KVITT_SECRETS_DIR}/fcm-service-account.json:/secrets/fcm-service-account.json:ro` as on the Pi; a missing file is already handled (push disabled).
|
||||
|
||||
**L-6. Any link can prefill any server URL on the pairing screen.**
|
||||
Location: `android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt:53-56`, `android/feature/onboarding/.../InviteLink.kt:36-52` (`fromAppLink`, `fromLandingLink`), manifest intent filters `android/app/src/main/AndroidManifest.xml:26-47`; SPEC AC-2.6, D-084.
|
||||
Issue: `kvitt://invite?server=https://attacker&code=…` from any app or web page, or `https://<anyhost>/i/<CODE>`, opens S-1 with the attacker's server in the field. An unpaired phone (first install, or after a revoke) could be talked into pairing with a rogue server, which then receives everything the phone pushes. Mitigations in place: a paired phone ignores the link (D-084, the session gate only shows S-1 when unpaired); the URL is visible and editable; release builds require `https://`.
|
||||
Fix (android, small): on S-1 show a warning line when the prefilled server is not the default `https://icehack.asuscomm.com:8443` ("Unusual server"); no server change.
|
||||
|
||||
**L-7. Notification text (description, amount) shows on the lock screen.**
|
||||
Location: `android/app/src/main/kotlin/se/hagstedt/kvitt/push/Notifications.kt:82-84` (no `setVisibility`/`setPublicVersion`).
|
||||
Issue: with Android's default "show sensitive content" setting a bystander reads expense descriptions and amounts from a locked phone. Spec §13.3 defines the copy; it does not say what the lock screen shows.
|
||||
Fix (android): `setVisibility(VISIBILITY_PRIVATE)` with `setPublicVersion(...)` carrying the generic text already defined for the sync-failed case.
|
||||
|
||||
**L-8. The Gitea workflow expands the registry token inside a shell line.**
|
||||
Location: `.gitea/workflows/build-images.yaml` ("Log in to Gitea Container Registry": `echo "${{ secrets.kvitt_cicd }}" | docker login …`).
|
||||
Issue: `${{ }}` is substituted into the script text before the shell runs it: a token containing a quote or `$` breaks the line (and could inject), and the value appears in the step's process arguments on the runner. The token also needs `write:repository` (for the tag push) on top of `write:package`.
|
||||
Fix (infra): pass it as `env: { REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }} }` and use `printf '%s' "$REGISTRY_TOKEN" | docker login …`; keep the scopes minimal (the tag push can use the checkout token if Gitea provides one).
|
||||
|
||||
**L-9. Images are pulled by tag over plain HTTP from the LAN registry.**
|
||||
Location: `infra/pi/compose.yaml` (`${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}`), `infra/pi/install.sh` (`insecure-registries`), `infra/pi/deploy.sh`; SPEC §10.3, D-001/D-104 (LAN is trusted).
|
||||
Issue: anyone on the home Wi-Fi who can answer for `gitea.lan` (or the Gitea Pi itself, if compromised) can hand the Kvitt Pi a tampered server image that sees every token hash and all data. Accepted by the spec's trust in the LAN; noted because the server is the one component that holds everything.
|
||||
Fix (infra, optional): have `deploy.sh` pin the digest the workflow printed (`image@sha256:…` in `.env`), or enable TLS on Gitea later.
|
||||
|
||||
**L-10. A member can tell whether a device id belongs to the other user.**
|
||||
Location: `server/crates/kvitt-server/src/api/devices.rs:57-63` (404 for unknown, 403 for another user's device).
|
||||
Issue: trivial information leak between the two trusted users; listed for completeness.
|
||||
Fix (server): return 404 in both cases, or leave as is.
|
||||
|
||||
### Info
|
||||
|
||||
**I-1.** `X-Kvitt-Server-Version` on every response and `GET /health` (`server_version`, `schema_version`, `db_ok`) are visible to unauthenticated scanners (`src/router.rs:125-131`, `src/api/health.rs`). Spec-mandated; the version is not a secret. Accept.
|
||||
|
||||
**I-2.** Room database and Coil's picture cache are unencrypted at rest (`android/data/.../di/DataModule.kt`, `repo/Pictures.kt`); the token key is Keystore AES-GCM without `setUnlockedDeviceRequired`/StrongBox (`data/auth/TokenCipher.kt:50-58`). This relies on Android file-based encryption and the lock screen, as §8.2 intends; the lost-phone case is covered by revocation (AC-3.3). Accept.
|
||||
|
||||
**I-3.** The landing page sends no `Content-Security-Policy`/`X-Content-Type-Options` (`src/api/landing.rs:196-210`). Output is escaped and the page is static with inline CSS only; `Cache-Control: no-store` and `Referrer-Policy: no-referrer` are set. Optional one-liner: `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'`.
|
||||
|
||||
**I-4.** No vulnerability scan of Rust dependencies is part of the server gate (no `cargo audit`/`cargo deny` in `tools/` or the hooks); `tools/dependency-report/latest.py` covers the Android catalog only. This review did not run one (offline). Suggest `cargo audit` as a periodic owner step or in the gate.
|
||||
|
||||
**I-5.** Client-IP correctness to verify after deploy: the rate limiter trusts the last `X-Forwarded-For` entry from Caddy (`rate_limit.rs:203-227`). If Docker's userland proxy ever fronts port 8443 (IPv6 without `ip6tables`, or `userland-proxy` quirks), Caddy sees the bridge gateway for every client and all internet clients share one 60/min bucket and one 10-failed-accepts lockout. Check once in Caddy's log that `request.remote_ip` is a public address.
|
||||
|
||||
**I-6.** `bootstrap` and `pair-device` print the invite code to stdout (`src/cli/bootstrap.rs:41-62`). Through `docker compose run --rm` the line also sits in that one-off container's json log until the container is removed. Expected; the owner reads it and it expires in 72 h.
|
||||
|
||||
**I-7.** The Ktor/OkHttp client keeps default redirect handling (`data/api/KvittApi.kt:246-248`). Neither the server nor Caddy redirects, and TLS excludes a third party, so the bearer cannot be bounced to another host. Fine.
|
||||
|
||||
**I-8.** `infra/secrets/` on the Mac: directory mode 0700; private key files and the service-account, rclone and `google-services.json` files mode 0600; two `.pub` files 0644. One file (`gitea_push_ed25519`) is not among the secrets SPEC §10 lists; presence only, not read.
|
||||
|
||||
## 3. Checked and found fine
|
||||
|
||||
* **Tokens (§8.2):** 32 CSPRNG bytes via `getrandom`, `kvt_` + base64url, stored only as SHA-256 hex (`api/invites.rs:79-86`, `auth.rs:46-53`); lookup by `UNIQUE token_hash` (`migrations/0001_init.sql:29`); revoked → `device_revoked` before any work; token never logged (log line carries route template, status, latency, device id only, `router.rs:134-162`); the hourly touch is in a `BEGIN IMMEDIATE` transaction.
|
||||
* **Invite codes (§8.3, AC-2.3, AC-2.12):** 60 random bits from the CSPRNG, Crockford alphabet, normalisation and hashing as specified; single-use/expiry/cancel checked inside the write transaction; every failure is the same `invite_invalid`; 10 failed accepts per IP per hour then 429 (`rate_limit.rs:46-49, 251-265`); `admin_setup` refused once a user exists; `household_full` enforced.
|
||||
* **Authorization (§8.4):** `AdminDevice` on create/list/cancel invites; devices listing filtered for members; revoke rules and `cannot_revoke_current_device`; `/me` edits only the caller; pushed expenses/settlements must name household users (`sync/check.rs:92-110, 185, 204-222`).
|
||||
* **SQL:** every query uses `sqlx` bind parameters; no string-built SQL found in `server/crates`; path ids must be canonical hyphenated UUIDs (`api/mod.rs:95-113`).
|
||||
* **Files and paths:** APK names must match `^kvitt-[0-9]+\.apk$` and be regular files (`apk.rs:22-26, 80-92`); picture paths are built from a parsed UUID and an integer version; writes are atomic (temp + rename); `latest.json` is read on each request, never cached stale.
|
||||
* **Body limits:** 1 MiB default and 5 MiB for `PUT /me/picture` in the router; Caddy `max_size 6MB`; sync batch 1 to 100 mutations, pull page ≤ 1000; notes/description/name limits applied after trimming; control characters refused in notes.
|
||||
* **Error leakage:** `internal` returns only a request id to the client and logs the detail (`error.rs:225-234`); 404 for unknown paths; serde messages reach the client only for the caller's own malformed JSON.
|
||||
* **Panics/unsafe:** `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]` in `lib.rs` and `main.rs`; `unsafe_code = "forbid"` in the workspace; mutex poisoning handled (`rate_limit.rs:164-169`).
|
||||
* **Push (§14.6):** data-only payloads with ids only; service-account `Debug` never prints the key (`push/fcm.rs:64-71`); access token cached in memory only; `UNREGISTERED` clears the token only if unchanged; log lines carry device id and push type, never the FCM token.
|
||||
* **Server container (§8.9):** uid 10001, `read_only`, `cap_drop: [ALL]`, `no-new-privileges`, no published port, distroless runtime pinned by digest, single secret file mounted read-only; only Caddy publishes 8443; TLS from files, `auto_https off`, admin API not published; Caddy ≥ 2.11 defaults give TLS 1.2+ and the `Authorization` header is redacted (verified by the owner 2026-10-18 per §10.5.2).
|
||||
* **cert-sync:** `StrictHostKeyChecking=yes` with a dedicated known_hosts, `BatchMode`, `IdentitiesOnly`, fixed remote command, PEM/key-match/expiry checks before install, atomic rename, uid 1000, `cap_drop: [ALL]`.
|
||||
* **Backups:** online `.backup` + `integrity_check`; copy-only with `--immutable`, confirmed by `lsf` before local deletion; `rclone.conf` used through a 0600 `mktemp` copy that is removed on exit; log lines carry names and sizes only.
|
||||
* **Secrets in git:** `.gitignore` covers `local.properties`, `*.jks`, `infra/secrets/`, `*.pem`, `*service-account*.json`, `google-services.json`, `*.sqlite*`, `infra/pi/.env`, `infra/pi/out/`, `infra/pi/secrets/`; the pre-commit hook refuses those patterns; `git ls-files` and the add-history of all branches contain no secret-looking file; `.env.example` files hold placeholders only (the Drive folder id is not a secret).
|
||||
* **Android (§8.5, §8.8):** release network config: cleartext off, system anchors; pinned-root variant limited to one domain and only with `kvitt.tls.pinnedRoot=true`; debug cleartext confined by `normaliseServerUrl` to `https://`, `localhost`, `10.0.2.2` and the three private ranges (`SessionRepositoryImpl.kt:102-124`); `allowBackup="false"` and extraction rules excluding every domain for both cloud backup and device transfer; exported components: `MainActivity` only; `KvittMessagingService` and `InstallResultReceiver` not exported; WorkManager default initializer removed in favour of the Hilt factory.
|
||||
* **Android token storage (§8.2):** Keystore AES-256-GCM, alias `kvitt_token_key`, 128-bit tag, Keystore-generated IV, ciphertext+IV in `auth.preferences_pb`; `Credentials.toString` redacts the token; on `device_revoked` the preferences are cleared, the key deleted, and database, prefs and picture caches wiped (`SessionManager.kt`, `DataModule.kt:166-170`); on `unauthorized` only the token is dropped.
|
||||
* **Android network:** bearer only on authenticated calls and, for Coil, only on URLs under the paired base URL (`PictureAuth.kt`); the token is never put in a URL; the accept call sends no bearer; log statements in `app`, `data` and `adapter` carry status codes and exception class names only.
|
||||
* **In-app update (FR-16):** release-only; file name validated against the same regex as the server; download to app-private cache; size bounded during download and SHA-256 + size verified before `PackageInstaller`; file deleted on any failure; Android enforces signing-key continuity on the update, so a tampered APK from a compromised server could not replace the installed app; `canRequestPackageInstalls` explained once; R8 enabled; `mapping.txt` never served.
|
||||
* **Push handling on the phone:** untrusted FCM data mapped to a typed event with defaults (`PushMapping.kt`), unknown `type`/`v` → sync only; ids are passed to WorkManager as strings and looked up in Room, never rendered raw.
|
||||
* **CSV import:** runs only as an operator CLI against a file path given on the command line, inside one transaction, with header and row validation and a reconciliation gate; not reachable over HTTP.
|
||||
* **OpenAPI:** top-level `security: [device]`; exactly the six public operations (E-1 to E-6) declare `security: []`; the server router applies `AuthDevice`/`AdminDevice` on every other route.
|
||||
|
||||
## 4. Owner questions
|
||||
|
||||
1. **Backups (M-1):** encrypt the Drive backups with an rclone `crypt` remote (passwords in `rclone.conf`, restore documented), and re-authorise rclone with the `drive.file` scope? This changes §8.7's last bullet and §10.9 and needs a decision entry.
|
||||
2. **HA SSH key (M-2):** which `authorized_keys` variant did the Home Assistant add-on accept at checklist step 3? If B (plain key), is a dedicated non-root user possible on that Pi?
|
||||
3. **Picture limit (M-3):** may AC-4.4's 8000 × 8000 become a pixel-count limit (for example 16 M pixels) or a decoder allocation limit, given the 160 MiB container?
|
||||
4. **APK download rate (L-3) and `fcm_token` length (L-4):** are a separate per-IP limit for `/apk/files/*` and a 256-character cap acceptable as new §8.6/§9.6 numbers?
|
||||
5. **After the first deploy (I-5):** one check of Caddy's access log that `request.remote_ip` shows public client addresses, so the per-IP limits key on real clients.
|
||||
Reference in New Issue
Block a user