SPEC v0.4: M2a and M2b decisions, handoff to the real state

Records the decisions proposed in the M2a (fdf2093) and M2b (fad6fc9)
reports: D-005 OpenAPI equality normalisations and CommonRules, D-006
bp_sum_not_10000 token, D-007 per-crate no-unwrap deny, D-010 Roborazzi,
D-011 IncompleteCustomRangeException, D-012 Room 3 with M2b's entities and
1.json as a contract file, D-013 JDK 25 Robolectric flags, D-014
failOnNoDiscoveredTests (D-008, D-009 unused). Clarifications in §6, §8.2,
§8.6, §9.6, §10.3, §10.4, §11, §14.2, §14.3, §14.5, §14.7, §14.8 and
AC-11.7. PLAN-M5 builds on the existing entities; v0.4 readings added to
PLAN-M3, M5, M7b to M7f. HANDOFF rewritten: next free D-040, spec v0.5,
server schema 2, Room schema 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:40:42 +02:00
parent 06f48ef9e9
commit 6dce4c73a9
14 changed files with 143 additions and 81 deletions
+11 -1
View File
@@ -9,10 +9,20 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| D-002 | 2026-10-17 | Backup retention on the Pi: local snapshot files are deleted as soon as their upload to Drive is confirmed (`rclone lsf`), not after 30 days. Drive keeps everything. | Owner's choice; the Pi's disk is small and Drive is the archive. | SPEC §10.9 step 5; PLAN-M2c-pi |
| D-003 | 2026-10-17 | Splitwise CSV format confirmed from two real exports (OQ-2 answered). English variant header `Date,Description,Category,Cost,Currency,Amelie Bennich,Jacob Hagstedt` (group "hemma", account "Hemma"); Swedish variant header `Datum,Beskrivning,Kategori,Kostnad,Valuta,Jacob Hagstedt,Amelie Bennich` (general group, account "Vardag", about 1750 rows). Member columns differ in order, so the importer reads member names from the header. ISO dates, point decimal, double-quoted fields with commas, trailing empty line then `Total balance` / `Totalsumma`. Payment category `Payment` / `Betalning`. All rows SEK. Swedish category names are mapped through a committed table (unknown names to `other`, counted as a warning). The partner's display name is Amelie. The CSV files stay in `export/` (git-ignored), never committed or quoted. | Real exports differ by Splitwise UI language. | SPEC §10.8, §12; PLAN-M9 (mapping notes), PLAN-M11 |
| D-004 | 2026-10-17 | Shells and ownership. M2a creates shell files in M3, M4 and M9 paths (501 handlers, stub subcommands `bootstrap`, `pair-device`, `rotate-epoch`, a `kvitt-import` crate whose `main.rs` prints usage); M2b creates a module shell for every module of §6.2 with one empty entry composable per feature. The later milestone owns each shell from the moment it lands and writes the body. M2a owns `server/Cargo.lock`, every `Cargo.toml`, `main.rs`, `lib.rs`, config, `serve` and `migrate`, `router.rs` and its own tests (any `tests/*.rs` without the `core_`, `sync_`, `scheduler_`, `push_` prefixes); later milestones may add dependency lines to `Cargo.toml`. | PLAN-M2a and PLAN-M2b open points (a), (b), (c): the skeletons need files in paths §14.7 gave to later milestones. | SPEC §11.2, §11.3, §14.7; PLAN-M2a, PLAN-M2b, PLAN-M3, PLAN-M4, PLAN-M6, PLAN-M7a to M7f, PLAN-M9 |
| D-005 | 2026-10-17 | The OpenAPI equality test (`server/crates/kvitt-server/tests/openapi_contract.rs`) parses both documents and compares values after eight normalisations: key order ignored; local `$ref` inlined, discriminator mappings included; `description` and `example` keywords dropped; nullable type arrays equal `oneOf` with `null`, nested `oneOf` flattened and compared as sets; `const` equals a single-value `enum`; `required: false` and `deprecated: false` dropped, path parameters always required; parameters keyed by (`in`, `name`); each operation compared on its effective security. The document-wide rules of §14.3.1 and §14.3.2 live in one place, the `CommonRules` modifier in `src/openapi.rs`. | M2a report (`fdf2093`): utoipa's output differs from the hand-written YAML only in form; D-016 needed the exact list. | SPEC §11.3, §14.8 |
| D-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); 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-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 |