853c038c58
Records the decisions proposed by M3 (503cee5) and M5 (c28c52b). M5's proposals D-030 to D-033 are renumbered D-040 to D-043; D-035 to D-039 are reserved for M4. Adds the D-027 clock-skew re-stamp rule, M5's contract requests for M8 (PLAN-M8, SPEC 11.3), the connect-info note for M4 (PLAN-M4), the picture size note (PLAN-M6), and updates prompts/README status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
19 KiB
19 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-17 | Reserved for M4 (in flight). | ||
| D-036 | 2026-10-17 | Reserved for M4 (in flight). | ||
| D-037 | 2026-10-17 | Reserved for M4 (in flight). | ||
| D-038 | 2026-10-17 | Reserved for M4 (in flight). | ||
| D-039 | 2026-10-17 | Reserved for M4 (in flight). | ||
| 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 |