Files
kvitt/docs/decisions.md
T
jacobh2 d9dd5848fe SPEC v0.6: M4 decisions (D-035 to D-039), HANDOFF rewritten
- D-035 unusable mutation_id/entity_type/entity.id fails the whole push with 400; other shape errors per mutation.
- D-036 0 mutations is batch_too_large ("outside 1 to 100").
- D-037 never-synced delete runs full step-8 validation; current on every rejection; created_at kept; canonical ids.
- D-038 FCM retries: one send plus three retries (1/5/25 s), 401 drops the token, other 4xx not retried.
- D-039 pruning strictly older than 90 days; tasks::start returns AppState with the push queue, SqlAuditWriter default.
- Clarifications: step 8 order, negative since, pull read transaction, FCM JWT kid and cache, token clearing only if
  unchanged, unusable credentials, scheduler clock jumps, rotate-epoch exit codes, /i/ empty code, image build needs
  a C toolchain and CA roots (dev image rebuilt 2026-10-18).
- PLAN-M4 pruning wording, prompts/README status, HANDOFF at bd6f10a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 07:33:46 +02:00

22 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 (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-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-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: key-based SSH to the Kvitt Pi and the Gitea credentials are HUMAN steps; the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint; the Gitea Actions workflow lives at .gitea/workflows/, owned by M2c-pi; secret files on the Pi (ha_ssh_key, ha_known_hosts, rclone.conf) mode 0600 owned by uid 1000, the uid of the cert-sync and backup containers; adding gitea.lan to insecure-registries is its own HUMAN step. PLAN-M2c-pi open points (a) to (c). SPEC §10.2, §10.3, §11.2, §11.3, §14.7; PLAN-M2c-pi
D-024 2026-10-17 Release keystore: keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000, created by the owner and kept outside the repository (was android/keystore/release.jks); local.properties holds its absolute path. §10.6 did not fix the key parameters (PLAN-M10 H-1); a keystore outside every checkout cannot be committed or deleted with a worktree. SPEC §10.6, §11.2, §11.3; PLAN-M10
D-025 2026-10-17 Production Splitwise import (M11): a backup first; user ids resolved by display name from the database; CSVs copied to the Pi into /opt/kvitt/import/, bind-mounted read-only into server as /import; the import runs as docker compose run --rm --entrypoint kvitt-import server ... with the server stopped (--entrypoint because the image's entrypoint is kvitt-server); CSVs deleted afterwards. The category table stays at server/crates/kvitt-import/data/splitwise_categories.csv. PLAN-M11 open points (a) to (c); PLAN-M9 table location. SPEC §10.2, §10.8, §11.2, §11.3; PLAN-M2c-pi, PLAN-M9, PLAN-M11
D-026 2026-10-17 A 409 cursor_ahead on pull is treated as epoch recovery: cursor 0, epoch cleared, every local expense and settlement marked pending with a new pending_mutation_id, cycle restarted. M5 report (c28c52b): a cursor ahead of the server means the server lost rows, as after a restore without rotate-epoch. SPEC §14.4.2
D-027 2026-10-17 A clock_skew rejection gives the row a new pending_mutation_id and the row stays pending (the server stored the rejection under the old id and would replay it). Orchestrator addition: on the first sync attempt after a clock_skew rejection, the phone re-stamps updated_at of each such pending row to its current clock before pushing, so a corrected clock heals the row. The re-stamp is not in :data at c28c52b: contract request for M8 or a small :data follow-up (HANDOFF open work). M5 report; without the re-stamp a row stamped by a wrong clock would stay rejected after the clock is fixed. SPEC §14.4.5
D-028 2026-10-17 Periodic sync work is named kvitt-sync-periodic (policy KEEP); one-off work keeps kvitt-sync (APPEND_OR_REPLACE). M5 report: WorkManager cannot share one unique name between periodic and one-off work. SPEC §14.4.1, AC-15.3
D-029 2026-10-17 A REST call that fails on the network returns OnlineResult.Failed("network", null); network is the one allowed code outside §14.3.2. M5 report: the UI must tell "Could not reach the server" (§9.9) from Offline (call not attempted). SPEC §14.2.4
D-030 2026-10-17 Picture version after a removal: E-10 deletes the JPEGs and leaves an empty <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-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-040 2026-10-17 A push refused as a whole (400 invalid_request, 413 payload_too_large, 409 household_incomplete, 422 batch_too_large) counts as a batch with no progress: the cycle goes on to pull and records last_error_code; 5xx and 429 retry with backoff; 401 and 426 stop. (Proposed by M5 as D-030, renumbered.) M5 report: §14.4.5 covered only network errors, 5xx and 401. SPEC §14.4.5
D-041 2026-10-17 :data's DataModule provides implementations only; the interface bindings live in DataBindingsModule (@DisableInstallInCheck), which :app includes for release so the debug fakes do not duplicate bindings. (M5's D-031, renumbered.) M5 report: Hilt fails on duplicate bindings when debug binds fakes. SPEC §6.2, §11.3 (M8); PLAN-M8
D-042 2026-10-17 Room tests run on Robolectric with the framework SQLite driver; migration tests build from the exported schema JSON rather than room3-testing. (M5's D-032, renumbered.) M5 report: no device needed for the gate. SPEC §6.2
D-043 2026-10-17 Two extra DataStore keys: the pairing status in auth.preferences_pb, and the installed version code that received a 426 in prefs.preferences_pb (syncing stops until the installed version changes). The token is encrypted with Keystore key kvitt_token_key, AES-256-GCM (as §8.2). (M5's D-033, renumbered.) M5 report. SPEC §8.2, §14.2.2, §14.4.5