d9e189abd5
Build Kvitt images / build (push) Successful in 5m45s
D-125 (owner decision 2026-10-20, landed6e5981d): the Kvitt Pi runs Kvitt from /home/jacobhagstedt/kvitt, a self-contained compose project with relative bind mounts, both containers as the owner's uid/gid, the seven .env keys compose reads, no scripts and nothing under /opt; operations are plain docker compose commands. Revises D-103, D-105, D-117 point 5 and D-120 point 6. SPEC §10.2, §10.6, §10.8 to §10.10, §11.2 and §14.7 rewritten; KVITT_PI_HOST placeholders become <kvitt-pi-address>. §10.3 records the Gitea workflow's first results (run 69: v1.0.0, backup 5 min 20 s, server 2 h 42 min; v1.0.1 from the sparse-checkout push) and the sparse checkout of3820f9c. HANDOFF, prompts/README.md and notes in PLAN-M11.md and PLAN-M2c-pi.md follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
95 KiB
95 KiB
Decisions
One entry per decision that changes or refines SPEC.md. Numbered, dated, never deleted: a revised decision gets a new entry and the old one a strike-through plus a pointer. The spec is updated in the same change.
| # | Date | Decision | Why | Where it lives |
|---|---|---|---|---|
| D-001 | 2026-10-17 | Development-first deployment. During development the server runs locally on the owner's M4 Mac (Docker compose infra/dev/compose.yaml or cargo run) and debug builds pair against it over the home Wi-Fi. M2c splits into M2c-dev (Mac compose stack, Firebase setup, local backup job; runs now) and M2c-pi (Pi provisioning, cert-sync, router forward 8443, Gitea registry and Gitea Actions, Drive backups; after M8, before M10). The registry is Gitea at http://gitea.lan (plain HTTP, home Wi-Fi only); the owner provides an example Gitea Actions workflow later. OQ-1 answered; OQ-3 now blocks only M2c-pi. |
Owner wants to develop against a local server and defer the Pi. | SPEC §10 (intro, §10.1, §10.3, §10.7), §11.1, §11.2, §11.3, §12, §14.7; PLAN-M2c-dev, PLAN-M2c-pi |
| 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 (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 (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 |
| D-014 | 2026-10-17 | Gradle's failOnNoDiscoveredTests is off only for modules without src/test (the shells) and on everywhere else. |
M2b report: the shells have no tests yet; in every other module a test task that discovers nothing must fail the gate. | SPEC §6.2 |
| D-015 | 2026-10-17 | Server seams. The X-Kvitt-Protocol 426 check, the X-Kvitt-Server-Version header and the §8.6 body limits are layers in router.rs (M2a). M3's rate limit is a layer in src/api/rate_limit.rs (replacing src/ratelimit.rs) attached at one line in router.rs that M2a marks. M2a creates src/tasks/mod.rs with a start function called from serve; M4 owns src/tasks/**. M2a implements next_occurrence_date and the day clamping in kvitt-domain (Rust only, src/recurring.rs) and defines the traits AuditWriter (src/audit/) and PushQueue (src/push/) with no-op implementations; M4 replaces the implementations, M3 and M9 call the traits. Filled in by the docs agent: M3 writes the per-template recurring generation function in src/api/recurring.rs (E-28, E-29 and M4's scheduler call it), M3 tests the §9.5 generation cases, M4 the scheduler cases, and M4 lands after M3. |
PLAN-M2a (d), (e); PLAN-M3 (a), (c); PLAN-M4 (a), (b): M3 and M4 both need §9.5 logic, audit and push while running in parallel. | SPEC §6.3, §9.5, §11.1, §11.2, §14.5, §14.7; PLAN-M2a, PLAN-M3, PLAN-M4, PLAN-M9 |
| D-016 | 2026-10-17 | The OpenAPI equality test compares parsed values, not text. Representational edits to contracts/openapi.yaml (key order, $ref naming, description wording) need no spec bump or decision entry; they are listed in the plan report. |
PLAN-M2a open point (f); utoipa output differs from hand-written YAML in form only. | SPEC §11.3, §14.8; CLAUDE.md (Contracts are versioned); PLAN-M2a |
| D-017 | 2026-10-17 | Feature convention. Each feature exposes one composable entry point per screen taking a hiltViewModel()-provided ViewModel and navigation as plain lambdas; features never reference other features or the NavHost; :app owns the NavHost; repositories are injected through the :core:model interfaces, bound in :app to :data (fakes only in test and debug-preview source sets). The §14.5.4 analytics types live in :core:model, package se.hagstedt.kvitt.model.analytics. Strings: :app holds only the app name, :core:design the shared strings (the notification copy and channel name included), each feature its own; lint MissingTranslation is an error in every module; keys are prefixed by the feature name (§14.7 wins over §13.4, which said "by screen"); :core:design keys never start with a feature name. |
PLAN-M2b open points (c), (e), (f); M7a to M7f need one convention fixed in build-logic. |
SPEC §6.2, §13.4, §14.5.4, §14.7; PLAN-M2b, PLAN-M6, PLAN-M7a to M7f, PLAN-M8, PLAN-U2 |
| D-018 | 2026-10-17 | The empty fake scenario: household complete (two named users, no pictures), no accounts, exactly the ten default categories, no expenses, settlements, templates, rejections or pending changes. resolveRange with CUSTOM and a null start or end throws a typed subclass of IllegalArgumentException declared in :domain (named by M2b: IncompleteCustomRangeException, D-011); the caller maps it to validation_failed. |
PLAN-M2b open point (g) and the resolveRange signalling left open in v0.2. |
SPEC §6.2, §11.3, §14.5.4; PLAN-M2b |
| D-019 | 2026-10-17 | Shared UI needed by two features is built in :core:design by M6: the ledger rows (expense and settlement rows, used by S-5, S-12 and the pending list of S-16) and the expense editor fields (used by S-7 and S-19). Features never copy each other's components. |
PLAN-M6 open point (a); feature modules cannot depend on each other. | SPEC §6.2, §11.2, §11.3; PLAN-M6, PLAN-M7b, M7c, M7e, M7f |
| D-020 | 2026-10-17 | No bundled font: the app uses the Material 3 default typeface. No licence step. | PLAN-M6 open point (b); avoids a font licence clearance. | SPEC §6.2, §11.2, §13.4; PLAN-M2b, PLAN-M6, PLAN-U1, PLAN-U2 |
| D-021 | 2026-10-17 | sync_mutations pruning runs daily at 04:00 Europe/Stockholm (DST-aware), deleting rows whose received_at is older than 90 days. |
PLAN-M4 open point (c); the spec said only "daily". | SPEC §14.1 (rules text, not the DDL), §14.4.9, §11.2; PLAN-M4 |
| D-022 | 2026-10-17 | Development infrastructure (M2c-dev, landed c885aba). No new CLI installs: Firebase project and Android apps through the Firebase Management REST API with a gcloud access token, no Firebase CLI; rclone belongs to M2c-pi. The agent may write git-ignored secrets, google-services.json included, under infra/secrets/; M8 copies it into android/app/ with infra/dev/copy-google-services.sh. Phone health check against the dev server is a HUMAN step. Gate: docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config; shellcheck $(find infra -name '*.sh'). As built: dev stack without Caddy or TLS, bind-mounted data folder, the whole infra/secrets/ mounted read-only at /secrets (a missing key file means push disabled), env file infra/dev/.env.example (the Pi's stays infra/env.example for M2c-pi), kvitt-server healthcheck used by compose, backups with sqlite3 .backup (§10.9). The server must start when KVITT_TRUSTED_PROXY does not resolve. Firebase on kvitt-1337 with apps se.hagstedt.kvitt and se.hagstedt.kvitt.debug; service account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com with roles/firebasecloudmessaging.admin (roles/firebasemessaging.admin does not exist). |
Owner's tools already on the Mac; M2c-dev report. | SPEC §10 intro, §10.4, §10.7, §11.2, §11.3, §14.7; PLAN-M2c-dev, PLAN-M8 |
| D-023 | 2026-10-17 | M2c-pi: 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_hostsrclone.confcert-syncbackupbackup runs as uid 10001 and rclone.conf is owned by 10001:10001; revised by D-120: cert-sync and its secrets ha_ssh_key and ha_known_hosts are removed); 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; revised by D-100, D-105, D-120 |
| 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 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 (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. :data at 8f0e156: contract request for M8 or a small :data follow-up (HANDOFF open work).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 (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 (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 :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 (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 (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 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 (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 (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; android/gradle/libs.versions.toml unused and can be removed in a later cleanup.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: <sheet>_<lang>_<theme>.png), plus a 200 % font-scale variant for long states;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 " · Hushållet" for the household measure and " · 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 ", ". 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 snackbarHostState)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 (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 | 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.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. 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. 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.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) |
| D-089 | 2026-10-18 | Rejection.actor: User?, from updated_by_user_id of the server's current row stored in sync_rejections.server_current; S-16's stale copy names the partner when it is null. No protocol change. |
M7b report (§11.3 list): the stale copy always named the partner. | SPEC §14.2.4, §14.4.6 |
| D-090 | 2026-10-18 | New optional request header X-Kvitt-App-Version-Name on every operation (CommonRules), stored at E-3 and on the hourly last-seen update; blank or longer than 32 characters is ignored. Server migration 0003_device_version_name.sql adds devices.app_version_name TEXT CHECK (NULL or length 1..32); server schema 3, E-1 reports 3, locked contract copy with its byte-identity test, a new v2→v3 data-preserving migration test. app_version_name on the Device wire object; Device.appVersionName; S-20 shows "Kvitt 1.1". kvitt-import expects schema 3. Room schema unchanged at 1 (the phone stores no devices). |
M7f report (§11.3 list): S-20 showed the version code ("Kvitt 11") where the design shows the name. | SPEC §8.2, §10, §10.8, §14.1, §14.1.2 (new), §14.3.1, §14.3.3, §14.3.4 (E-1, E-3), §14.8, AC-3.1, §13.3 (S-20), §14.2.4; contracts/schema/migrations/0003_device_version_name.sql, examples E-1, E-3, E-7, E-15 |
| D-091 | 2026-10-18 | RecurringRepository.create(NewRecurringTemplate) takes the template fields without an id; the server mints it (E-28's body never had one) and create returns it. The S-19 editor builds RecurringForm.newTemplate() for a new template and template(original, …) for an edit. |
M7f report (§11.3 list): the editor sent TemplateId(""). |
SPEC §13.3 (S-19), §14.2.4, §14.3.4 (E-28) |
| D-092 | 2026-10-18 | UpdateRepository.check(): UpdateCheck = UpToDate | Available(info) | Failed. A 404 from E-5 means up to date; debug builds report UpToDate without a request. S-22 shows settings_about_up_to_date or, on failure, common_could_not_reach_server. Revises D-073 and AC-16.6. |
M7f report (D-073, §11.3 list): S-22 could not tell "up to date" from a network failure. | SPEC AC-16.6, §13.3 (S-22), §14.2.4 |
| D-093 | 2026-10-18 | SettleState.untouched: List<UntouchedAccount(account, payerNetOre)>: accounts not in the rows where the payer is owed, with nets that exclude the settlement being edited, in AccountRepository.all() order; empty for a per-account settle-up. Computed in :data and in the fakes. S-9 uses it and no longer reads Home nets. Revises D-074's S-9 reading. |
M7d report (D-074, §11.3 list): Home balances include the settlement being edited. | SPEC §13.3 (S-9), §14.2.4 |
| D-094 | 2026-10-18 | The D-027 re-stamp as implemented: when sync_state.clock_skew is set at the start of a cycle, every pending expense and settlement whose updated_at (or a tombstone's deleted_at) is later than the phone's clock is re-stamped to now, in one Room transaction before pushing; rows stamped no later than now are untouched. Room schema unchanged. Revises D-027. |
M5 report (D-027, §11.3 list): the re-stamp was not in :data. |
SPEC §14.4.5 step 3 |
| D-095 | 2026-10-18 | PushEvent lives in :core:model (model/Push.kt); :adapter:fcm keeps PushListener and PushMapping. Six screens host the shared snackbarHostState in their own scaffold (Home, AccountDetail, ExpenseDetail, SettlementDetail, DrillDown, SyncStatus); :app has no overlay host. ExpenseEditorScreen.onSaved(ExpenseId, @StringRes Int?), onDeleted() without argument on S-7 and S-8; :app shows snackbar_expense_deleted. Launcher icon: adaptive, vectors only, mipmap-anydpi/ic_launcher.xml, background #9C5238 (light primary), foreground and monochrome receipt_long mark at 48 dp, manifest icon and roundIcon; lint MissingApplicationIcon cleared. Revises D-071. |
M8 report (§11.3 list): PushEvent was in the adapter, the overlay host could cover a FAB or the bottom bar, S-7 and S-8 handed over resolved strings, the app had no launcher icon. |
SPEC §6.2, §13.2, §13.3 (S-7, S-8), §14.2.4, §14.6, §11.3 |
| 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 (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), Caddyfileinstall.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/opt; plain docker compose commands in /home/jacobhagstedt/kvitt; the backup before every deploy stays). 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); scps it to /opt/kvitt/apk/; install-apk.sh copies it into the data volume, writing latest.json last and keeping the newest 5scps the APK, then latest.json, into ~/kvitt/data/apk/; older APKs removed by hand); 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.200c99f, read by nothing on the Pi; the registry keys dropped by D-125, whose .env holds exactly the seven keys compose reads) 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 (revised by D-125) |
| D-104 | 2026-10-18 | Images ${KVITT_REGISTRY}/kvitt-server, kvitt-cert-synckvitt-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); opensslcaddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7afalpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6, 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 KVITT_UID/KVITT_GID; the development stack keeps 10001), 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~/kvitt/secrets/rclone.conf, the owner's, mode 0600). 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./certs removed by D-120; revised by D-125: no install.sh, the owner creates data/apk, backups and import before the first start) 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 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 " (schema )" (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) 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.location ^~ /i/ without access or error log; NPM logs no request headers) (2) 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 (revised by D-120: set at NPM's server level with the server's copies hidden). (3) 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).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;docker image inspect with the workflow's digests) the workflow prints the pushed digests. (6) kvitt_cicd needs only write:package (the tag push uses the checkout credentials). (7) 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 |
| D-118 | 2026-10-18 | Shared test support lives in :core:design's test fixtures (src/testFixtures, package se.hagstedt.kvitt.design.testing: Variant, TestClock, TestEnvironment, ScreenSnapshots with capture modes Root/Screen/ScreenWhenIdle, MainDispatcherRule, withMain), consumed as testImplementation(testFixtures(project(":core:design"))); not a module, so the §6.2 graph is unchanged; the architecture check rejects test fixtures outside test configurations. The convention plugin kvitt.android.screenshots wires Roborazzi and the test libraries; feature build scripts carry no screenshot block. Shared failure texts (format/Failures.kt: FAILURE_NETWORK, commonFailureText), limits (format/Limits.kt: BP_TOTAL, DISPLAY_NAME_MAX, DESCRIPTION_MAX, NOTES_MAX, FIRST_DATE, DAYS_AHEAD_MAX) and savedSplitLabel live in :core:design. KvittMotion holds only the seesaw spring; KvittIcon.Description and unused dimens/shapes removed; catalog entries roborazzi-junit-rule, roborazzi-compose, androidx-hilt-navigation-compose, androidx-lifecycle-viewmodel-compose removed; one CopyCategoryKeywords task shared by :data and :core:fakes. 941 tests, no golden changed, release APK 10.46 MB. Landed by the Android overhaul (e4e6e5d to c582363). |
Eight modules each had a copy of the same screenshot harness, and M12f had to fix the same capture bug in two of them; test fixtures rather than a :core:testing module let :core:design's own tests use them, add no module to the §6.2 graph and are treated by lint as test sources. The failure-text map, the limits (BP_TOTAL in five copies) and the saved-split label were duplicated across features; the removed tokens, icon and catalog entries were never referenced. |
SPEC §6.2 (:core:design row, Screenshot tests), §11.1, §11.3; docs/ux/design-spec.md §2.11 and the icon table; android/README.md |
| D-119 | 2026-10-19 | APK download rate limit. E-6 GET /apk/files/{file_name} is limited to 10 requests per minute per client IP in its own fixed window (APK_DOWNLOAD_LIMIT in src/api/rate_limit.rs), separate from and not counted against the 60 per minute of the other unauthenticated endpoints; the client IP as in D-032 (peer, or the last X-Forwarded-For entry from the trusted proxy); refusals are 429 rate_limited with Retry-After; every request counts, a 404 included; in memory, pruned like the other windows. E-5 GET /apk/latest and E-4 GET /i/{code} stay on the 60 per minute. Landed 96e4cbd (unit test and an integration test in tests/core_api/rate_limit.rs). Owner: "APK download limit assumed is OK". |
Security review L-3: each E-6 answer can be a ~10 MB APK, so 60 per minute per IP let one client pull about 600 MB a minute from the Pi; E-5 and E-4 answers are small and the phone checks E-5 at most every 6 hours (AC-16.2). | SPEC §8.6, §10.6, §14.3.4 (E-6), §11.4 |
| D-120 | 2026-10-19 | TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi instead of Caddy and cert-sync, which are removed (infra/cert-sync/**, infra/pi/Caddyfile, images, volumes certs and caddy_data, secrets ha_ssh_key and ha_known_hosts, .env keys HA_PORT, HA_USER, HA_CERT_DIR). (1) Kvitt gets its own public host name KVITT_PUBLIC_HOST (owner-chosen, recorded in HANDOFF; kvitt.suorra.duckdns.org on 2026-10-19), because icehack.asuscomm.com is NPM's proxy host for Home Assistant and NPM selects by name. KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443https://${KVITT_PUBLIC_HOST}, port 443). A path prefix on another name is not supported. (2) The router forwards external TCP server (publishes 8080 on all LAN interfaces, hardening unchanged) and backup (unchanged, D-105). NPM forwards plain HTTP over the LAN (accepted by the owner). (4) KVITT_TRUSTED_PROXY=${HA_HOST}, NPM's LAN IPv4 address, checked by install.sh (no check since D-125); the server is unchanged and takes the last X-Forwarded-For entry from that peer (the server's built-in defaults, the OpenAPI server URL and the E-7/E-12 examples that still named the old host: revised by D-123). (5) The NPM proxy-host settings and the Custom Nginx Configuration of §10.5.2 carry over D-117's headers and log redaction: headers at server level with the server's copies hidden; its own location / without add_header (NPM's default adds X-Served-By, which makes nginx drop server-level headers); location ^~ /i/ with access_log off and error_log /dev/null crit because NPM's access log writes $request_uri with the query string; client_max_body_size 6m; proxy_read_timeout 60s; HSTS on (NPM's 2 years). (6) http://localhost:8080/health on the Pi, then through NPM (warning only); install.sh removes the old stack's leftovers.run line (printf '%s' "${{ secrets.kvitt_cicd }}" | docker login …), the owner's proven form, because the env-based form came through empty on act_runner 0.6.1 with a user-level secret; this reverses L-8's 'never expanded inside a shell line'; act_runner masks the value. Revises D-103 (bundle files), D-104 (images, Caddy pin, cert-sync packages, trigger path), D-106 (cert-sync part), D-117 parts 1, 2, 3, 7, 8, D-023's cert-sync secrets. Verified on the Mac 2026-10-19: nginx -t against NPM v2.16.0 templates; rehearsal server-behind-nginx 21 checks passed (headers once each, HSTS, http→https 301, 413 at 7 MB, rate limit per forwarded client, forged X-Forwarded-For contained, invite code/name//i/ absent from nginx logs including on 502). |
Owner 2026-10-19: NPM already manages Let's Encrypt; the HA name is taken; 8443 → NPM 443 confirmed. | SPEC §8.5, §8.7, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.7, §10.10, §11.2, §14.7 |
| D-121 | 2026-10-19 | The Android app's public host is a build property. kvitt.publicHost is read from the Gradle property (-Pkvitt.publicHost=…), else the key kvitt.publicHost in android/local.properties (owner's, git-ignored), else the placeholder kvitt.example.invalid (RFC 2606; never resolves, so a release built without it cannot reach a real host). The build refuses a value that is not a host name. :app exposes it as BuildConfig.PUBLIC_HOST, as the manifest placeholder publicHost (the AC-2.6 https intent filter, /i/, autoVerify="false") and as RELEASE_SERVER_URL = https://<host>:8443https://<host>) (link/ReleaseServer.kt). :feature:onboarding receives that URL through the Hilt qualifier @ReleaseServerUrl for S-1's prefill and its non-default-server check. Invite-link parsing still accepts a server named by the link (AC-2.6). Tests and goldens use fixed fixture hosts. infra/publish-apk.sh passes -Pkvitt.publicHost from KVITT_PUBLIC_HOST (4b1ae0c, D-122; S-22's fallback server also D-122). The pinned-root config's domain (D-082) is still the old literal and must follow the property if that fallback is ever prepared. Landed d090c6c. |
D-120 gave Kvitt its own host name; it was hard-coded in three places. | SPEC AC-2.6, AC-2.7, §8.5, §10.5.3, §10.6, §13.3 S-1, §12 OQ-6; android/README.md |
| D-122 | 2026-10-19 | S-22's server before pairing comes from the build (extends D-121). AppBuildInfo (defined in :data di/DataModule.kt, provided by :app in di/AppBuildInfoModule.kt, consumed by :data) gains releaseServerUrl: String, filled from RELEASE_SERVER_URL = https://<kvitt.publicHost>:8443https://<kvitt.publicHost>): data class AppBuildInfo(val versionCode: Int, val versionName: String, val debug: Boolean, val releaseServerUrl: String). S-22's server row shows the paired server's base URL; while none is stored it shows AppBuildInfo.releaseServerUrl (AboutRepositoryImpl's releaseServerUrl, was defaultServerUrl); DataModule.DEFAULT_SERVER_URL (https://icehack.asuscomm.com:8443) is removed, so :data names no host. Fakes and tests use https://kvitt.example.org:8443 (FakeScenarios, SettingsScenarios, RepositoriesTest; 30 S-21/S-22 goldens re-recorded with that fixture host, only the host changed); new test about_unpaired_showsTheReleaseServerOfTheBuild. infra/publish-apk.sh reads KVITT_PUBLIC_HOST from infra/pi/.env without sourcing it (refuses a missing file or key, CHANGE_ME, a value that is not a host name such as a URL, or the old name icehack.asuscomm.com, the checks of install.sh), passes -Pkvitt.publicHost to :app:assembleRelease and prints the release server in its header and summary. Landed 4a4e45f (Android) and 4b1ae0c (publish-apk.sh, infra/README.md). |
D-121 left S-22's unpaired fallback on Home Assistant's host and publish-apk.sh without the host, so a release could be built for the placeholder. |
SPEC §6.2, §10.6, §13.3 S-22, §14.2.4 |
| D-123 | 2026-10-19 | The server has no built-in Kvitt host (revises D-120's leftovers). (1) KVITT_PUBLIC_URL has no default (DEFAULT_PUBLIC_URL removed from config.rs): unset or empty, every kvitt-server command exits 1 with a configuration error (configuration: KVITT_PUBLIC_URL is not set …) before opening the database; the Pi compose sets https://${KVITT_PUBLIC_HOST}:8443https://${KVITT_PUBLIC_HOST}), the dev stack http://<Mac LAN address>:<port> (infra/dev/up.sh; compose fallback http://localhost:8080). (2) KVITT_TRUSTED_PROXY defaults to none (NO_TRUSTED_PROXY, ignoring case and surrounding white space): no peer trusted and the value never looked up; empty or unresolvable also means no proxy (D-022 unchanged); compose sets HA_HOST. (3) The OpenAPI server is https://{host}:8443https://{host}) with server variable host, default kvitt.example.invalid (RFC 6761 reserved), description 'the owner-chosen Kvitt host name, SPEC §10.5', in contracts/openapi.yaml and the generated document (openapi.rs PUBLIC_SERVER_URL, HOST_VARIABLE_DEFAULT); operations unchanged (representational for §14.8). (4) Examples E-7 public_url and E-12 link use https://kvitt.example.invalid:8443https://kvitt.example.invalid). tools/contracts-check.py expects the {host} URL (e381e37). Tests: CLI every_command_refuses_to_start_without_a_public_url (serve, bootstrap, migrate; no database created), config public_url_is_required, rate limiter the_default_none_trusts_no_peer, an_unresolvable_name_trusts_no_peer, an_ip_literal_is_trusted; 276 server tests. server/README.md updated. Landed c9abd24, e381e37. |
Since D-120 the old name icehack.asuscomm.com is Home Assistant's; a wrong built-in host is worse than none, and caddy named a container that no longer exists. |
SPEC §10.4, §11.3, §14.3, §14.3.4 (E-7, E-12) |
| D-124 | 2026-10-19 | Kvitt is served on port 443 (https://<host>, no explicit port) because NPM listens on 443 and selects the proxy host by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, and there is no 8443 rule (revises D-120 point 2 and the :8443 of D-120 to D-123). (1) Server and contracts (a86ad24): the OpenAPI server is https://{host} (contracts/openapi.yaml, openapi.rs PUBLIC_SERVER_URL); examples E-7 public_url and E-12 link use https://kvitt.example.invalid without a port; tools/contracts-check.py expects the portless header; the server's tests, configuration message and README name no port. (2) Bundle (4852803): KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST} in infra/pi/compose.yaml; the health checks through NPM use https://<KVITT_PUBLIC_HOST>/health on 443 (infra/pi/lib.sh); infra/publish-apk.sh prints the release server without a port; infra/README.md step 7 is "Router: no new rule" (verify the existing TCP 443 rule to <HA_HOST>); the APK-abuse stop-gap is now "disable Kvitt's proxy host in NPM", because the 443 forward also serves Home Assistant. (3) Android (038c603): releaseServerUrl(host) = https://<host> (RELEASE_SERVER_PORT removed); the AC-2.6 https intent filter carries no android:port, which matches every port of the host, so an old :8443 link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (:443 for https, :80 for http); the S-1 fixture GOLDEN_SERVER is https://kvitt.example.org (28 S-1 goldens re-recorded, only the server row changed). |
Owner decision 2026-10-19: NPM serves every proxy host on 443 and picks Kvitt's by name, so a second external port adds a router rule and nothing else; the owner confirmed that external 443 already forwards to the Home Assistant Pi. | SPEC §1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §14.2.4, §14.3, E-7, E-12 |
| D-125 | 2026-10-20 | The Pi runs Kvitt from /home/jacobhagstedt/kvitt, a self-contained compose project: compose.yaml, .env, secrets/, relative bind mounts ./data, ./backups, ./import; containers run as the owner's uid/gid (KVITT_UID/KVITT_GID, 1000 on the Pi); no install script, no /opt, no Docker daemon or system changes (registry trust and Docker are prerequisites); operations are plain docker compose commands. Details: .env holds exactly the seven keys compose reads (KVITT_REGISTRY, KVITT_TAG, KVITT_PUBLIC_HOST, HA_HOST, KVITT_UID, KVITT_GID, RCLONE_FOLDER_ID); RCLONE_REMOTE fixed to gdrive: in compose; private packages → one-time docker login gitea.lan; each container mounts only its own secret file read-only (create_host_path: false); read-only root, 16 MB tmpfs /tmp, backup HOME=/tmp; secret files the owner's, mode 0600. Equivalents of the removed scripts (install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh, lib.sh): backup docker compose exec backup /backup.sh; bootstrap docker compose exec server kvitt-server bootstrap; partner invite docker compose exec server kvitt-server invite-partner; deploy = backup, set KVITT_TAG, pull, up -d; rollback = the previous tag; APK = scp the APK, then latest.json, into data/apk/ (old APKs removed by hand); digests via docker image inspect. Restore moves files on the host; only rclone and sqlite3 run in the backup container. Compose default network. The development stack is unchanged (uid 10001). Revises D-103 (scripts, /opt/kvitt, .env keys), D-105 (uid and rclone.conf owner on the Pi), D-117 point 5 (the digest record is dropped), D-120 point 6 (health checks via scripts dropped). Landed 6e5981d. Rehearsed locally with Podman as uid 501: both containers healthy, /health 200, bootstrap, APK served, backup in three modes (no folder id, a local remote, a failing remote), the restore steps. |
Owner decision 2026-10-20: 'It should be a self contained docker compose thing', 'do not install anything in any other place'. | SPEC §8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 (OQ-3), §14.7; infra/pi/compose.yaml, infra/pi/.env.example, infra/README.md "Pi: what the owner does", infra/RESTORE.md, PLAN-M11.md and PLAN-M2c-pi.md notes |