From d9dd5848fe03250d4404598886bf1cc3835df8b1 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 07:33:46 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- PLAN-M4.md | 2 +- SPEC.md | 63 ++++++++++++++++++++++++++--------------------- docs/HANDOFF.md | 43 ++++++++++++++------------------ docs/decisions.md | 10 ++++---- prompts/README.md | 4 +-- 5 files changed, 61 insertions(+), 61 deletions(-) diff --git a/PLAN-M4.md b/PLAN-M4.md index 13379ad..e7a7afe 100644 --- a/PLAN-M4.md +++ b/PLAN-M4.md @@ -51,7 +51,7 @@ Resolved open points (SPEC v0.3; the start prompt may repeat them): - `src/scheduler/**`: the daily 06:00 Europe/Stockholm tick (and one run at start) calling M3's generation function for every active template; the daily 04:00 `sync_mutations` pruning. - `src/cli/rotate_epoch.rs`: the subcommand. - `tests/sync_*.rs`: every rule of §14.4.2 and §14.4.4 steps 1 to 12, each rejection reason of §14.4.6, the §9.10 conflict cases, replay idempotency, batch limits, cursor_ahead, epoch change visible in pull, a 14-day/200-change offline scenario with a scripted clock (server side of AC-15.6). -- `tests/scheduler_*.rs`: the scheduler cases of §9.5: run at start, next 06:00 tick computed across both DST changes, catch-up after downtime through M3's function, paused and deleted templates skipped; the 04:00 pruning (rows of 90 days and older removed, newer kept). The per-template generation cases are M3's tests. +- `tests/scheduler_*.rs`: the scheduler cases of §9.5: run at start, next 06:00 tick computed across both DST changes, catch-up after downtime through M3's function, paused and deleted templates skipped; the 04:00 pruning (rows with `received_at` strictly older than 90 days removed; a row exactly 90 days old and newer rows kept, D-039). The per-template generation cases are M3's tests. - `tests/push_*.rs`: payloads for every type against `contracts/examples/push/`, recipients, `sync_batch` threshold, retries and delays, token clearing on `UNREGISTERED`/404, queue overflow, disabled without credentials, all against a local stub HTTP server. ## 6. Steps diff --git a/SPEC.md b/SPEC.md index 3df1de5..2e1dde2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.5** (2026-10-17). Schema version **1**. Sync protocol version **1**. Push payload version **1**. +Version **0.6** (2026-10-18). Schema version **1**. Sync protocol version **1**. Push payload version **1**. This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it. @@ -452,8 +452,8 @@ Features never call `:domain` directly: repositories return computed state (bala | Time | chrono with chrono-tz for Europe/Stockholm (M2a confirmed; no other time crate) | DST-correct scheduling | | Ids | uuid crate, v7 | Same format as phones | | Images | image crate (decode JPEG/PNG/WebP, encode JPEG) | Pure Rust, no system libs | -| HTTP client (FCM) | reqwest with rustls | No OpenSSL on the image | -| FCM auth | Service-account JWT (RS256, jsonwebtoken crate) exchanged at `https://oauth2.googleapis.com/token` for an access token with scope `https://www.googleapis.com/auth/firebase.messaging`, cached until 5 minutes before expiry | HTTP v1 API needs OAuth2 | +| HTTP client (FCM) | reqwest with rustls (crypto provider aws-lc-rs, certificates checked by the platform verifier); needs a C toolchain at build time and CA roots in the image (§10.3) | No OpenSSL on the image | +| FCM auth | Service-account JWT (RS256, jsonwebtoken crate, header `kid` = the key file's `private_key_id`) exchanged at `https://oauth2.googleapis.com/token` for an access token with scope `https://www.googleapis.com/auth/firebase.messaging`, cached until 5 minutes before expiry | HTTP v1 API needs OAuth2 | | Scheduler | A tokio task inside `kvitt-server` (no extra crate) | One process, simple | Cargo workspace (directory `server/`): @@ -467,8 +467,8 @@ Cargo workspace (directory `server/`): **Server seams (D-015).** So that M3, M4 and M9 work in parallel on M2a's skeleton: * **Middleware in `router.rs` (M2a):** the `X-Kvitt-Protocol` check (426 `protocol_unsupported`), the `X-Kvitt-Server-Version` response header and the §8.6 body limits (1 MiB, 5 MiB for `PUT /me/picture`) are layers in `src/router.rs`. On authenticated routes the protocol check runs before the token check, so a request with a bad protocol header and a bad token gets 426, not 401. M3's rate limit (§8.6, AC-2.12) is a layer defined in `src/api/rate_limit.rs` and attached at one line in `router.rs` that M2a marks with a comment; that line is the only one M3 edits in `router.rs`. -* **Background tasks:** M2a creates `src/tasks/mod.rs` with a `start` function that `serve` calls once after migrations (initially doing nothing). M4 owns `src/tasks/**` and starts the scheduler, the push sender and the `sync_mutations` pruning from it. -* **Audit and push interfaces:** M2a defines the traits `AuditWriter` (in `src/audit/`) and `PushQueue` (in `src/push/`) with no-op implementations, and passes them through the application state (`AppState.audit`, `AppState.push`). M4 replaces the implementations (audit writer and diff of §14.4.7, FCM queue of §14.6); M3 and M9 only call the traits. As M2a built them (exact signatures in `server/README.md`): +* **Background tasks:** M2a creates `src/tasks/mod.rs` with a `start` function that `serve` calls once after migrations (initially doing nothing). M4 owns `src/tasks/**` and starts the scheduler, the push sender and the `sync_mutations` pruning from it. As M4 built it (D-039): `tasks::start` returns the `AppState` with the real push queue installed, and `serve` builds the router from that returned state. +* **Audit and push interfaces:** M2a defines the traits `AuditWriter` (in `src/audit/`) and `PushQueue` (in `src/push/`) with no-op implementations, and passes them through the application state (`AppState.audit`, `AppState.push`). M4 replaces the implementations (audit writer and diff of §14.4.7, FCM queue of §14.6); M3 and M9 only call the traits. Since M4 (D-039): `AppState::new` holds `SqlAuditWriter` by default and `NoopAuditWriter` no longer exists; `NoopPushQueue` stays as the queue when push is disabled (§14.6), and `tasks::start` installs the bounded `PushHub` otherwise. As M2a built them (exact signatures in `server/README.md`): * `AuditWriter::write` takes the caller's open connection inside its `BEGIN IMMEDIATE` transaction and one `AuditEvent` (entity type and id, action, source, actor user and device, `client_at`, `server_at`, and the wire object before the change, absent for a create, and after it, as JSON), and returns the new audit entry's id or an `AppError`. The writer derives `changes` (§14.4.7) and takes the entry's `server_version` from that same transaction. * `PushQueue::enqueue` takes one `PushMessage` (push type, one of the eight of §14.6; entity id or none; actor user id; count). It never blocks and never fails; callers call it after commit. * **Recurring generation:** M2a implements `next_occurrence_date` and the day clamping in `kvitt-domain` (§14.5.2). M3 writes the one function that generates the due periods of one template (§9.5: candidate periods, one transaction per candidate, audit through `AuditWriter`, push through `PushQueue`) in `src/api/recurring.rs`, used by E-28 and E-29; M4's scheduler calls that function for every template. M4 therefore lands after M3 (it may start in parallel and rebases on M3 before its gate). @@ -577,7 +577,7 @@ Two trusted users. The server is on the public internet on port 8443. Threats in * Unauthenticated endpoints: at most 60 requests per minute per client IP, then 429 `rate_limited` with a `Retry-After` header in seconds. * Authenticated endpoints: at most 600 requests per minute per device. -* Keying (D-032): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A request answered 401 also counts against its client IP's unauthenticated limit, and a token that was never accepted is refused with `rate_limited` once that IP is over its limit. All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (`KVITT_TRUSTED_PROXY`, re-resolved at most every 60 seconds), the last entry of `X-Forwarded-For`. Per-IP keying needs the peer address: `serve` runs the router with `into_make_service_with_connect_info::()` (added by M4); without it every client shares one IP bucket. +* Keying (D-032): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A request answered 401 also counts against its client IP's unauthenticated limit, and a token that was never accepted is refused with `rate_limited` once that IP is over its limit. All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (`KVITT_TRUSTED_PROXY`, re-resolved at most every 60 seconds), the last entry of `X-Forwarded-For`. Per-IP keying needs the peer address: `serve` runs the router with `into_make_service_with_connect_info::()` (added by M4 in `bd6f10a`, D-032 fulfilled). * `GET /health` from the container healthcheck counts against the loopback IP's limit; at the compose healthcheck interval this stays far below 60 per minute and is accepted. * Invite accept failures: AC-2.12. * Request bodies: 1 MiB maximum, except `PUT /me/picture` at 5 MiB. Larger: 413 `payload_too_large`. Both limits are layers in `router.rs` (§6.3). @@ -694,7 +694,7 @@ Consequences, stated so nobody "fixes" them: ### 9.5 Recurring generation -* The scheduler runs once at server start and then daily at 06:00 Europe/Stockholm (DST-aware: always local 06:00). +* The scheduler runs once at server start and then daily at 06:00 Europe/Stockholm (DST-aware: always local 06:00). It reads the injected clock (`AppState.clock`) at least every 30 seconds rather than sleeping until the next run: after a forward clock jump past one or more runs it runs the missed job once; after a backward jump of more than a day it plans the next run again from the new time. The 04:00 pruning loop (§14.1) follows the same rules. * For each template with `deleted_at IS NULL` and `paused = 0`, the candidate periods are every month `p` with `start_period ≤ p`, `p ≤ end_period` (if set), `p > last_generated_period` (if set), and whose occurrence instant (the clamped date at 06:00 Europe/Stockholm) is at or before now. * For each candidate in ascending order the server, in one transaction: creates the expense (server-generated UUIDv7 id; `expense_date` = clamped date; fields copied from the template; `created_by_user_id` and `updated_by_user_id` = the template's `created_by_user_id`; `updated_by_device_id` null; `created_at` = `updated_at` = server now; `recurring_template_id` and `recurring_period` set), writes its splits (mode `default` resolves the account's current default ratio at generation time; other modes use the template's splits, with `ratio`/`full` recomputed by the ratio rule for the template amount), writes an audit entry (`create`, `source` = `recurring`), sets the template's `last_generated_period` and `updated_at` (server now), bumping its `server_version`, then enqueues the push (AC-9.6). * The unique index on `(recurring_template_id, recurring_period)` makes generation idempotent; a conflict means the period was already generated and is skipped silently. @@ -726,8 +726,9 @@ All string lengths are counted in Unicode code points after trimming. One table, | `start_period` | `YYYY-MM`, not earlier than 12 months before the current month | `validation_failed` | | `end_period` | `null` or `YYYY-MM` ≥ `start_period` | `validation_failed` | | `updated_at` in a push | ≤ server now + 10 minutes | `clock_skew` | -| mutations per `POST /sync` | 1 to 100 | `batch_too_large` | +| mutations per `POST /sync` | 1 to 100; 0 is also `batch_too_large` (D-036) | `batch_too_large` | | `GET /sync` `limit` | 1 to 1000, default 500 | `invalid_request` | +| `GET /sync` `since` | required, integer ≥ 0 | `invalid_request` | | picture upload | ≤ 5 MiB, ≤ 8000 × 8000 px, JPEG/PNG/WebP | `payload_too_large`, `image_invalid`, `unsupported_media_type` | Strings are stored as validated, that is trimmed (§8.6, D-034). @@ -838,7 +839,7 @@ File `infra/compose.yaml`, deployed to `/opt/kvitt/compose.yaml` on the Pi with ### 10.3 Images, registry and build * Script `infra/build-images.sh [tag]` on the Mac: `docker buildx build --platform linux/arm64 --push` for the three Kvitt images, tag defaults to the short git SHA of `HEAD`, also tags `latest`. Refuses to run with a dirty working tree. -* `server` image: multi-stage. Stage 1 `rust:-bookworm`, where `` is the exact Rust release pinned in `server/rust-toolchain.toml` (the current stable when M2a pins it; the two always change together), builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) pinned by digest, with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. The Dockerfile selects the Rust target from `TARGETARCH`: `arm64` (`aarch64-unknown-linux-gnu`) is the shipped target; `amd64` (`x86_64-unknown-linux-gnu`) also builds, emulated on the Mac, for local use only. +* `server` image: multi-stage. Stage 1 `rust:-bookworm`, where `` is the exact Rust release pinned in `server/rust-toolchain.toml` (the current stable when M2a pins it; the two always change together), builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) pinned by digest, with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. The Dockerfile selects the Rust target from `TARGETARCH`: `arm64` (`aarch64-unknown-linux-gnu`) is the shipped target; `amd64` (`x86_64-unknown-linux-gnu`) also builds, emulated on the Mac, for local use only. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (`infra/dev`, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly; that the shipped Pi image has a working C stage and CA roots, and that an FCM send succeeds from it, is confirmed by M2c-pi or M10. * `cert-sync` image: `alpine` with `openssh-client`, `curl` and a shell script `infra/cert-sync/sync.sh` run by `crond`. * `backup` image: `alpine` with `sqlite`, `gzip`, `tar`, `rclone` and `infra/backup/backup.sh` run by `crond`. * Script `infra/deploy.sh ` on the Mac: copies `compose.yaml` and `Caddyfile` to the Pi over SSH, sets `KVITT_TAG` in `/opt/kvitt/.env`, runs `docker compose pull && docker compose up -d`, waits for `server` to be healthy, prints `GET /health`. @@ -874,7 +875,7 @@ Files under `KVITT_DATA_DIR`: | `serve` | Run migrations, start scheduler, push sender and HTTP server | 0 on clean shutdown (SIGTERM drains in-flight requests up to 10 s) | | `bootstrap` | FR-1 | 0 ok, 2 already initialized | | `pair-device --user ` | AC-1.6 | 0 ok, 3 unknown user | -| `rotate-epoch` | Sets a new random `sync_epoch` (§10.9) | 0 | +| `rotate-epoch` | Sets a new random `sync_epoch` and prints it on stdout (§10.9) | 0 ok, 1 database error | | `healthcheck` | Calls `http://127.0.0.1:/health` (8080 by default), exit 0 if 200; used by the compose healthchecks of both stacks (no curl in the image) | 0 or 1 | | `migrate` | Run migrations only | 0 | @@ -1082,7 +1083,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's | M2c-dev | Infra for development (D-001, D-022) | Landed `c885aba`. `infra/dev/compose.yaml` (`server` built from `server/Dockerfile`, no Caddy, no TLS, bind-mounted data, `infra/secrets/` mounted read-only at `/secrets`, `backup` runnable on demand), `infra/dev/.env.example`, the dev scripts in `infra/dev/` (build, up, logs, backup, `copy-google-services.sh`), `infra/README.md` (dev stack and Firebase), `infra/backup/{Dockerfile, backup.sh}` (Drive upload skipped when `RCLONE_FOLDER_ID` is empty), Firebase project and apps through the REST API (§10.7), service-account key and `google-services.json` in `infra/secrets/` | `docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Mac (with the M2a image): `GET /health` answers 200 from the Mac; one local backup run produces a snapshot that passes `PRAGMA integrity_check` | `gcloud auth login`; on the phone on the home Wi-Fi, open the dev server's `/health` and answer yes/no that it shows `"status":"ok"` | M0 (uses the M2a image for the health check when available) | | M2c-pi | Infra on the Pi (D-001) | `infra/` Pi compose stack, Caddyfile, cert-sync image, build, deploy and publish scripts, `infra/README.md` (Pi provisioning, router forwarding, trust of the `gitea.lan` registry), `infra/RESTORE.md`, Drive backups to folder id `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`, Gitea Actions image build from the owner's example workflow in `.gitea/workflows/`, the `/opt/kvitt/import` bind mount (§10.2) | `docker compose -f infra/compose.yaml config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Pi: cert-sync fetched a valid certificate, `https://icehack.asuscomm.com:8443/health` answers from the phone on mobile data; one backup file appears in the Drive folder | Answer OQ-3; grant at the start of the sprint the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive (D-023); set up key-based SSH from the Mac to the Kvitt Pi; provide the example Gitea Actions workflow and the Gitea credentials; install Docker on the Pi if missing; add `gitea.lan` to `insecure-registries` on the Pi; set the router forward 8443 → Kvitt Pi; add the cert-sync public key to the Home Assistant SSH add-on; `rclone authorize "drive"` | M2c-dev, M8 | | M3 | Server core API | Landed `503cee5` (D-030 to D-034). E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (`src/api/rate_limit.rs`), `bootstrap`, `pair-device`, the recurring generation function of §6.3 (D-015) | Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, `next_occurrence_date`) | none | M2a | -| M4 | Server sync, audit, push, scheduler | E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) | +| M4 | Server sync, audit, push, scheduler | Landed `bd6f10a` (D-035 to D-039). E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) | | M5 | Android data layer | Landed `c28c52b` (D-026 to D-029, D-040 to D-043). DAOs on M2b's Room 3 entities and `KvittDatabase` v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b | | M6 | Design system | `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts, formatters (§9.8), shared strings (including the notification copy) per `docs/ux/design-spec.md` and `docs/ux/strings.csv`, screenshot tests of every component in light and dark, Swedish and English | Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b, the §9.7 colour change | | M7a | Feature: onboarding | `:feature:onboarding` screens S-1, S-2, S-3, S-18 | Android gate with screenshot tests of every state, sv and en, light and dark | none | M6 | @@ -1104,7 +1105,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's * **M5** owns `:data` and builds on the entities, `KvittDatabase` v1 and the exported schema `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` that M2b created (D-012); it adds DAOs, the API client, sync and the repositories. Room 3 migrations take an `SQLiteConnection` (§14.2.3). * **M7x** features use `:core:fakes` only; they never depend on `:data`. Each feature agent adds its fake scenarios in `android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes//` and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from `:core:design` (D-019). * **M2c-dev / M2c-pi** (D-001): M2c-dev builds only what development needs on the Mac and runs now; M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) and runs after M8, before M10. -* **M4** adds `into_make_service_with_connect_info::()` to `serve` so the per-IP rate limits of §8.6 see the peer address (D-032). +* **M4** (landed `bd6f10a`) added `into_make_service_with_connect_info::()` to `serve` so the per-IP rate limits of §8.6 see the peer address (D-032). Its tests are three binaries, `tests/sync_api/`, `tests/push_fcm/` and `tests/scheduler_daily/` (§14.7); the scheduler lives in `src/tasks/scheduler.rs`. * **M8** is the only plan that edits `:app` after M2b. M5's contract requests (v0.5): `:app` provides `AppBuildInfo(versionCode, versionName, debug)` and includes `DataBindingsModule` in the release graph (D-041); WorkManager is configured with `KvittWorkerFactory` and the default initializer disabled; `:app` calls `SyncTriggers.onAppStart`, `onForeground` and `syncForPush`, and `UpdateRepository.check` on cold start and every resume; the `ImageLoader` that `:data` provides is set as Coil's singleton. It owns the `:app` items network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), and copies `google-services.json` from `infra/secrets/` with `infra/dev/copy-google-services.sh` (§10.7). * **M10**: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 (D-024). * **M2c-pi**: 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 (D-023). @@ -1479,7 +1480,7 @@ Server-side rules on top of the DDL: * At most two rows in `users` (checked in code; `household_full`). * `meta.sync_epoch` is replaced by `kvitt-server rotate-epoch` with a new `lower(hex(randomblob(16)))`. * No row of `users`, `devices`, `accounts`, `categories`, `recurring_templates`, `expenses`, `settlements` or `audit_log` is ever hard-deleted. Child rows (`account_default_shares`, `recurring_template_splits`, `expense_splits`, `settlement_allocations`) are replaced (delete and insert) when their parent is updated. -* `invites` rows are never deleted; `sync_mutations` rows are pruned after 90 days by a daily job at 04:00 Europe/Stockholm (DST-aware, always local 04:00) that deletes rows whose `received_at` is older than 90 days (D-021). +* `invites` rows are never deleted; `sync_mutations` rows are pruned after 90 days by a daily job at 04:00 Europe/Stockholm (DST-aware, always local 04:00) that deletes rows whose `received_at` is strictly older than 90 days; a row exactly 90 days old is kept (D-021, D-039). ### 14.2 Room entities (phone) @@ -1783,7 +1784,7 @@ Exhaustive list: | `unsupported_media_type` | 415 | Picture with another content type | | `validation_failed` | 422 | A value breaks §9.6 or a split/settlement rule | | `image_invalid` | 422 | Picture cannot be decoded or exceeds 8000 × 8000 | -| `batch_too_large` | 422 | More than 100 mutations in `POST /sync` | +| `batch_too_large` | 422 | Number of mutations in `POST /sync` outside 1 to 100 (D-036) | | `protocol_unsupported` | 426 | Missing or unsupported `X-Kvitt-Protocol` | | `rate_limited` | 429 | §8.6, with `Retry-After` | | `internal` | 500 | Unexpected server error (logged with a request id, returned in `message`) | @@ -1880,7 +1881,7 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default ``` Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_failed`. -**E-4 `GET /i/{code}`** (none). 200 HTML per AC-2.5, for any `code` string of 1 to 32 characters (validity is not checked). Otherwise 404 HTML. +**E-4 `GET /i/{code}`** (none). 200 HTML per AC-2.5, for any `code` string of 1 to 32 characters (validity is not checked). Otherwise 404 HTML, `GET /i/` with an empty code included. **E-5 `GET /apk/latest`** (none). 200 `latest.json` content (§10.6). Errors: `not_found` (nothing published). @@ -1937,7 +1938,7 @@ Errors: `invalid_request`, `invite_invalid`, `household_full`, `validation_faile **E-30 `DELETE /recurring-templates/{template_id}`** (device). 200 `RecurringTemplate` (tombstone). Errors: `not_found`. -**E-31 `GET /sync?since=&limit=`** (device). See §14.4.2. `since` is required (missing: `invalid_request`); `limit` is optional. Errors: `invalid_request`, `cursor_ahead`. +**E-31 `GET /sync?since=&limit=`** (device). See §14.4.2. `since` is required (missing or negative: `invalid_request`); `limit` is optional. Errors: `invalid_request`, `cursor_ahead`. **E-32 `POST /sync`** (device). See §14.4.3. Errors (whole request): `invalid_request`, `batch_too_large`, `payload_too_large`, `household_incomplete`. @@ -1965,8 +1966,9 @@ M1 writes one JSON example per request and response body above, plus one per err #### 14.4.2 Pull: `GET /sync?since=N&limit=L` -Server: +Server, in one read transaction per page (so the cursor check and the page see one snapshot): +0. `N` missing or negative, or `L` outside 1 to 1000: 400 `invalid_request`. 1. If `N` > current `sync_sequence.value`: 409 `cursor_ahead`. 2. Select the `L` (default 500, max 1000) rows with the lowest `server_version > N` across `users`, `categories`, `accounts`, `recurring_templates`, `expenses`, `settlements`, `audit_log` (a `UNION ALL` of `(server_version, table, id)` ordered by `server_version`), including tombstones. An expense with its splits, a settlement with its allocations, an account with its default shares and a template with its splits each count as one row. 3. Respond 200: @@ -2032,7 +2034,7 @@ Request (1 to 100 mutations, body ≤ 1 MiB): } ``` -The pushed `entity` contains exactly the client-writable fields: for expenses `id`, `account_id`, `category_id`, `amount_ore`, `description`, `notes`, `expense_date`, `payer_user_id`, `split_mode`, `splits`, `created_at`, `updated_at`, `deleted_at`; for settlements `id`, `from_user_id`, `to_user_id`, `amount_ore`, `settlement_date`, `note`, `allocations`, `created_at`, `updated_at`, `deleted_at`. Server-owned fields (`server_version`, `created_by_user_id`, `updated_by_user_id`, `recurring_template_id`, `recurring_period`) are ignored if sent. A delete is the full entity with `deleted_at` set. +The pushed `entity` contains exactly the client-writable fields: for expenses `id`, `account_id`, `category_id`, `amount_ore`, `description`, `notes`, `expense_date`, `payer_user_id`, `split_mode`, `splits`, `created_at`, `updated_at`, `deleted_at`; for settlements `id`, `from_user_id`, `to_user_id`, `amount_ore`, `settlement_date`, `note`, `allocations`, `created_at`, `updated_at`, `deleted_at`. Server-owned fields (`server_version`, `created_by_user_id`, `updated_by_user_id`, `recurring_template_id`, `recurring_period`) are ignored if sent. A delete is the full entity with `deleted_at` set. Every pushed id (`mutation_id`, `entity.id` and every id inside the entity) must be a UUID in the canonical form of the conventions (lowercase, hyphenated, 36 characters); another spelling of a valid UUID is not accepted (D-037). Response 200, one result per mutation in request order: @@ -2051,23 +2053,25 @@ Response 200, one result per mutation in request order: #### 14.4.4 Server processing of one mutation -The whole request runs in one `BEGIN IMMEDIATE` transaction; each mutation in its own `SAVEPOINT`, rolled back on rejection. Pushes are enqueued after commit. Steps, in order: +The whole request runs in one `BEGIN IMMEDIATE` transaction; each mutation in its own `SAVEPOINT`, rolled back on rejection. Pushes are enqueued after commit. Before the transaction, the whole request is checked: 0 or more than 100 mutations is 422 `batch_too_large` (D-036); a mutation whose `mutation_id` or `entity.id` is not a canonical UUID, or whose `entity_type` is not `expense` or `settlement`, makes the whole request fail with 400 `invalid_request` (without these keys no per-mutation result can be formed, D-035). Steps, in order: 1. **Replay.** If `mutation_id` exists in `sync_mutations`, return the stored `result` unchanged (no other effect). 2. **Household.** Fewer than two users: the whole request fails with 409 `household_incomplete` before any mutation. -3. **Shape.** Missing fields, wrong types, unknown enum values, ids that are not UUIDs: `rejected`, `validation_failed`. +3. **Shape.** Every other shape error (missing fields, wrong types, unknown enum values, ids that are not canonical UUIDs, timestamps not in the fixed form): `rejected`, `validation_failed`, per mutation (D-035). 4. **Clock.** `updated_at` > server now + 10 minutes: `rejected`, `clock_skew`. 5. **Load** the stored row by `id`. 6. **Stored row is a tombstone.** If the pushed entity is also a delete: `accepted`, no change, no audit, no push (idempotent delete). Otherwise `rejected`, `deleted`, `current` = the tombstone. 7. **Pushed entity is a delete** (`deleted_at` non-null): * Stored row exists: set `deleted_at` = pushed `deleted_at`, `updated_at` = the later of stored and pushed `updated_at`, keep every other stored field, `updated_by_*` from the caller, new `server_version`, audit `delete` with `changes` `[{"field":"deleted_at","old":null,"new":}]`, push `*_deleted`. `accepted`. This applies even when the pushed `updated_at` is older (deletes always win). - * No stored row (created and deleted before it ever synced): validate references as in step 8; insert the row as a tombstone with a new `server_version`, no audit, no push. `accepted`. -8. **Validate** the entity against §9.6, §9.1 (expenses) or §9.4 settlement validity (settlements), and references: `account_id`, every allocation `account_id`, `category_id` must exist and not be deleted (`reference_not_found` otherwise; archived is fine); user ids must be household users (`validation_failed`). Failure: `rejected` with that reason, `current` = stored row or null. + * No stored row (created and deleted before it ever synced): run the full validation of step 8 (field rules, domain rules and references), so the tombstone satisfies the schema; on failure `rejected` as in step 8. Otherwise insert the row as a tombstone with a new `server_version`, no audit, no push. `accepted` (D-037). +8. **Validate**, in this order: the field rules of §9.6, then the domain rules of §9.1 (expenses) or §9.4 settlement validity (settlements), then references: `account_id`, every allocation `account_id`, `category_id` must exist and not be deleted (`reference_not_found` otherwise; archived is fine); user ids must be household users (`validation_failed`). Failure: `rejected` with that reason, `current` = stored row or null. 9. **No stored row:** insert with `created_by_user_id` = `updated_by_user_id` = caller's user, `updated_by_device_id` = caller's device, `server_received_at` = now, new `server_version`; audit `create` with every diff field (`old` null); push `*_created`. `accepted`. 10. **Stored row exists, pushed `updated_at` ≤ stored `updated_at`:** `rejected`, `stale`, `current` = stored row. -11. **Stored row exists, newer:** compute the diff (§14.4.7). Empty diff: change nothing (not even `updated_at`), no audit, no push, `accepted` with the stored row and its existing `server_version`. Otherwise update fields and children, `updated_by_*` from the caller, new `server_version`, audit `update` with the diff, push `*_updated`. `accepted`. `recurring_template_id`, `recurring_period` and `created_by_user_id` keep their stored values. +11. **Stored row exists, newer:** compute the diff (§14.4.7). Empty diff: change nothing (not even `updated_at`), no audit, no push, `accepted` with the stored row and its existing `server_version`. Otherwise update fields and children, `updated_by_*` from the caller, new `server_version`, audit `update` with the diff, push `*_updated`. `accepted`. `recurring_template_id`, `recurring_period`, `created_by_user_id` and `created_at` keep their stored values (a pushed `created_at` is ignored on update, D-037). 12. Store `(mutation_id, device_id, entity_type, entity_id, result JSON, now)` in `sync_mutations`, for accepted and rejected results alike (step 1 then replays it). +Every rejection, at steps 3 and 4 too, carries `current` = the stored row with that `id`, or `null` when there is none (D-037). + #### 14.4.5 Phone push procedure 1. Read pending expenses and settlements ordered by `local_updated_at` ascending; take up to 100. Build each mutation from the row with `mutation_id = pending_mutation_id`. @@ -2130,7 +2134,7 @@ The phone renders `changes` with ids resolved to names (FR-12). | Periodic background sync | 15 minutes (WorkManager minimum), requires a connected network | | FCM-triggered sync | expedited one-off work | | Retry backoff | exponential from 30 seconds (WorkManager) | -| `sync_mutations` retention | 90 days, pruned daily at 04:00 Europe/Stockholm (D-021) | +| `sync_mutations` retention | 90 days, pruned daily at 04:00 Europe/Stockholm; rows with `received_at` strictly older than 90 days are deleted (D-021, D-039) | | Tombstone retention | forever | | Token lifetime | until revoked | | Notification sync wait (AC-13.5) | 20 seconds | @@ -2318,9 +2322,11 @@ Data-only messages (no `notification` block), all values strings. Types (exhaust Server sending rules: -* Sending happens on a background task fed by a bounded queue (capacity 100; when full, the oldest message is dropped with a warning log). It never delays an HTTP response. -* Retries: 3 attempts with delays 1 s, 5 s, 25 s on 429, 5xx and network errors. FCM `UNREGISTERED` or HTTP 404 for a token clears that device's `fcm_token`. -* Push is disabled (with a start-up warning) when the credentials file is missing. +* Sending happens on one background task fed by a bounded queue (capacity 100; when full, the oldest message is dropped with a warning log). It never delays an HTTP response. +* Authentication: the sender signs an RS256 JWT with header `kid` = the service account's `private_key_id`, exchanges it for an access token and caches the token until 5 minutes before its expiry (§6.3). +* Retries (D-038): one send plus up to three retries after 1 s, 5 s and 25 s (at most four sends) on 429, 5xx and network errors. On 401 the cached access token is dropped and that message is not retried; other 4xx are not retried. Retries run inside the single sender task, so one failing device can hold the queue for up to 31 s; accepted for two users. +* FCM `UNREGISTERED` or HTTP 404 for a token clears that device's `fcm_token`, only if the stored token is still the one that was sent (a token the phone replaced meanwhile is kept). +* Push is disabled (`NoopPushQueue`, one start-up warning) when the credentials file is missing or unusable (treated alike). Phone handling (`:adapter:fcm` maps the message to `PushEvent(type, entityType, entityId, actorUserId, count)` in `:core:model`; `:app` handles it): @@ -2377,7 +2383,7 @@ Ownership (an agent edits only paths its milestone owns; anything else needs the | `server/Cargo.toml`, `server/Cargo.lock`, every crate's `Cargo.toml`, `server/rust-toolchain.toml`, `server/Dockerfile`, `server/crates/kvitt-domain/**` | M2a | contract-change plans only for `kvitt-domain`; M3, M4 and M9 may add dependency lines to a crate's `Cargo.toml` (second to land resolves `Cargo.lock` on rebase) | | `kvitt-server`: `migrations/`, `src/{main,lib,config,error,router,openapi,auth,time}.rs` (including the `serve` and `migrate` code), `src/db/**`, `src/wire/**`, `src/api/{mod,health,setup}.rs`, `src/cli/{mod,healthcheck}.rs`, M2a's own tests (any `tests/*.rs` without the prefixes `core_`, `sync_`, `scheduler_`, `push_`) | M2a | contract-change plans; M3/M4 may add one line per new module to `mod.rs` files they need; M3 edits the one marked rate-limit line in `router.rs` (D-015) | | `kvitt-server`: `src/api/{invites,landing,me,pictures,devices,accounts,categories,recurring,proposal,apk,rate_limit}.rs`, `src/cli/{bootstrap,pair_device}.rs`, `tests/core_*.rs` (M3's tests are one binary, `tests/core_api/`) | M3 (M2a creates the shells, D-004) | later server plans | -| `kvitt-server`: `src/api/sync.rs`, `src/tasks/**`, `src/sync/**`, `src/audit/**`, `src/push/**`, `src/scheduler/**`, `src/cli/rotate_epoch.rs`, `tests/sync_*.rs`, `tests/scheduler_*.rs`, `tests/push_*.rs` | M4 (M2a creates the shells, the `tasks::start` stub and the `AuditWriter`/`PushQueue` traits with no-op implementations as described under "Server seams" in §6.3, D-004, D-015) | later server plans; the trait signatures change only with M3, M4 and M9 agreeing through the orchestrator | +| `kvitt-server`: `src/api/sync.rs`, `src/tasks/**`, `src/sync/**`, `src/audit/**`, `src/push/**`, `src/scheduler/**`, `src/cli/rotate_epoch.rs`, `tests/sync_*.rs`, `tests/scheduler_*.rs`, `tests/push_*.rs` (M4's tests are the binaries `tests/sync_api/`, `tests/push_fcm/`, `tests/scheduler_daily/`; its scheduler is `src/tasks/scheduler.rs`; there is no `src/scheduler/`) | M4 (M2a creates the shells, the `tasks::start` stub and the `AuditWriter`/`PushQueue` traits with no-op implementations as described under "Server seams" in §6.3, D-004, D-015) | later server plans; the trait signatures change only with M3, M4 and M9 agreeing through the orchestrator | | `server/crates/kvitt-import/**` | M9 (M2a creates a crate that builds with a usage-only `main.rs`, D-004) | same | | `android/settings.gradle.kts`, `android/build.gradle.kts`, `android/gradle.properties`, `android/gradle/**`, `android/build-logic/**`, the module shells (build file, manifest, one empty entry composable per feature) of `:core:design`, `:data`, `:adapter:fcm` and every `:feature:*` (D-004) | M2b | later plans may add catalog entries and module includes (rebase resolves) | | `android/core/model/**`, `android/domain/**` | M2b | contract-change plans only | @@ -2433,3 +2439,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op | 0.3 | 2026-10-17 | Orchestrator resolutions of the plans' open points. D-004: M2a and M2b create shell files and modules in later milestones' paths, ownership of `Cargo.lock` and every `Cargo.toml` (§11.2, §11.3, §14.7). D-015: server seams: middleware in `router.rs`, rate limit in `src/api/rate_limit.rs` on one marked line, `src/tasks/` for M4, `AuditWriter`/`PushQueue` traits with no-op implementations, `next_occurrence_date` and day clamping in `kvitt-domain` (Rust only), M3 writes the recurring generation function, M4 lands after M3 (§6.3, §9.5, §11, §14.5.2, §14.7). D-016: the OpenAPI equality test compares parsed values; representational edits need no spec bump (§11.3, §14.8). D-017: feature convention, analytics types in `se.hagstedt.kvitt.model.analytics`, string placement and key prefixes, `MissingTranslation` in every module (§6.2, §13.4, §14.5.4, §14.7). D-018: empty fake scenario; `resolveRange` throws a typed `IllegalArgumentException` subclass for an incomplete `CUSTOM` range (§6.2, §14.5.4). D-019: ledger rows and expense editor fields in `:core:design` (§6.2, §11.2). D-020: no bundled font, Material 3 default typeface (§6.2, §13.4). D-021: `sync_mutations` pruning daily at 04:00 (§14.1, §14.4.9). D-022: development infrastructure as M2c-dev built it (`c885aba`): Firebase through the REST API with a gcloud token, no Firebase CLI, secrets and `google-services.json` in `infra/secrets/` copied by `infra/dev/copy-google-services.sh`, Firebase resources and the `kvitt-fcm` service account (`roles/firebasecloudmessaging.admin`) exist; dev stack without Caddy or TLS, bind-mounted data, `infra/secrets/` mounted at `/secrets`, env file `infra/dev/.env.example`, `healthcheck` used by compose; the server starts when `KVITT_TRUSTED_PROXY` does not resolve; new M2c-dev gate (§10 intro, §10.4, §10.7, §11.2, §14.7). D-023: M2c-pi allowances, HUMAN steps, `.gitea/workflows/`, secret modes for uid 1000 (§10.2, §10.3, §11.2, §14.7). D-024: release keystore command, kept outside the repository (§10.6). D-025: production import procedure and the `/opt/kvitt/import` bind mount (§10.2, §10.8). Without a decision id: exact Rust version pin shared by `rust-toolchain.toml` and the Dockerfile (§10.3); the §9.7 colour change lands before M6 (§11.1); per-screen states are U2's list (§13.3); landing page copy (§13.3, AC-2.5); M8 owns network security config, hardening, signing and version properties (§11.2); U1's gate names the six phase-1 screens (§11.2); category table path (§10.8); implementation agents never edit the spec or the decisions log (§14.8). | | 0.4 | 2026-10-17 | Decisions from the M2a (`fdf2093`) and M2b (`fad6fc9`) implementation reports. D-005: the eight normalisations of the OpenAPI equality test and the `CommonRules` modifier in `openapi.rs` (§11.3, §14.8). D-006: the wrong-bp-sum token is `bp_sum_not_10000` with an explicit serde rename in Rust; Kotlin emits the same token (§14.5.1, §14.5.3). D-007: the no-unwrap rule enforced per crate with `#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]` (CLAUDE.md). D-010: screenshot tests with Roborazzi 1.76.0 on Robolectric 4.17, `@Config(sdk = [36])`, `roborazzi.test.verify=true`, re-record with `recordRoborazziDebug` (§6.2, §11.2). D-011: `IncompleteCustomRangeException` for an incomplete `CUSTOM` range; `ALL_TIME` with the earliest expense after today gives today..today (§14.5.4). D-012: Room 3 (`androidx.room3` 3.0.3, KSP only, `SQLiteDriver` with `sqlite-bundled`, migrations on an `SQLiteConnection`); M2b created the entities and `KvittDatabase` v1, whose exported schema `1.json` is a contract file; M5 builds on them (§6.2, §11.2, §11.3, §14.2, §14.2.3, §14.7, §14.8). D-013: JDK 25 `--add-exports`/`--add-opens` for Robolectric in `build-logic` (§6.2). D-014: `failOnNoDiscoveredTests` off only for modules without `src/test` (§6.2). D-008 and D-009 unused. Clarifications without a decision id: exact Rust pin (1.99.1) shared by `rust-toolchain.toml` and the Dockerfile, `TARGETARCH` arm64 (shipped) and amd64, distroless pinned by digest (§6.3, §10.3); chrono stands (§6.3); `kvitt-domain` depends only on `serde` (§6.3, CLAUDE.md); `AuditWriter` and `PushQueue` as built (§6.3, §14.7); protocol check before token check (§6.3, §14.3.1); 405 with an empty body and the version header (§14.3.1); body limits as router layers (§8.6); `app_version_code` updated only from a present, parseable `X-Kvitt-App-Version` (§8.2); E-1 `schema_version` from a code constant; E-2 `initialized` means an admin user exists (§14.3.4); `healthcheck` uses the port of `KVITT_BIND`; `cargo run -p kvitt-server -- ` from the workspace root (§10.4); `minSdk` 36, `compileSdk` and `targetSdk` 37, Material 3 1.4.0 pinned beside the Compose BOM, AGP 9 `check` runs only the debug unit-test variant, version code and name from `kvitt.versionCode`/`kvitt.versionName` defaulting to `1`/`dev`, feature convention documented in `android/README.md` (§6.2); `FieldError.error` may be `validation_failed` for §9.6 rules without a domain token (§9.6, §14.2.4); `AnalyticsState` has no error field and a missing custom date resolves as today (§14.2.4, §14.5.4); `DrillDownState.title` stays a `String` of unlocalised parts and S-12 formats its own title (§14.2.4); a settlement row in an account's list shows that account's allocation (AC-11.7, §14.2.4); the `AnalyticsMeasure.Share` / `model.Share` name collision is accepted (§14.2.4, §14.5.4). | | 0.5 | 2026-10-17 | Decisions from the M3 (`503cee5`) and M5 (`c28c52b`) implementation reports. M3: D-030 picture version after a removal, `-removed` marker, next version = highest on disk + 1, first upload 1 (AC-4.4, §10.4, E-10); D-031 failed accept = any 4xx but 429, refused until 60 minutes after the first failure, fixed windows (AC-2.12); D-032 rate-limit keying by token hash, 401 counts against the IP, never-accepted tokens refused when the IP is over, `KVITT_TRUSTED_PROXY` re-resolved every 60 s, last `X-Forwarded-For` entry, `into_make_service_with_connect_info` added by M4 (§8.6, §10.4, §11.3); D-033 resume moves `last_generated_period` only to a period at or after `start_period` (§9.5); D-034 strings stored trimmed, transparency flattened onto white, `fcm_token` non-empty or null, revoke clears `fcm_token`, idempotent archive keeps `server_version`, new category `sort_order` over non-deleted (§8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26). M5 (renumbered: its D-030 to D-033 are D-040 to D-043): D-026 `cursor_ahead` is epoch recovery (§14.4.2); D-027 `clock_skew` gives a new `pending_mutation_id`, and the phone re-stamps `updated_at` on the next attempt (orchestrator decision, follow-up open) (§14.4.5); D-028 periodic work `kvitt-sync-periodic` (§14.4.1); D-029 `OnlineResult.Failed("network", null)` (§14.2.4); D-040 whole-request push refusals count as no progress, 429 retries, 426 stops (§14.4.5); D-041 `DataBindingsModule` (§6.2); D-042 Room tests on Robolectric, migrations from the exported JSON (§6.2); D-043 pairing status and 426 version code in DataStore (§8.2, §14.2.2). D-035 to D-039 reserved for M4. Clarifications without a decision id: control characters restricted only in `notes`, newlines accepted in names and descriptions (§8.6); landing page without download button while no APK is published, 404 page, `Referrer-Policy` and `Cache-Control` headers (AC-2.5, §13.3); generation sets the template's `updated_at` (§9.5); E-28 accepts archived accounts and categories (AC-6.4); E-14 on an accepted invite is 204 with no change; `/health` from the healthcheck counts against loopback (§8.6); M3's tests in one binary `tests/core_api/` (§14.7); sync triggers, batch and page sizes (AC-15.3, §14.4.1); 426 stops syncing until the installed version changes and `HomeState` has no update-required field (§14.2.4, §14.3.1); pictures always fetched at 512; `NeedsInstallPermission` once (AC-16.3); `Rejection.summary`, `FieldChange` display and `RecurringTemplate.draft.date` as documented in `:data` (§14.2.4); M8 contract requests (§11.3). | +| 0.6 | 2026-10-18 | Decisions from the M4 (`bd6f10a`) implementation report. D-035: a mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable fails the whole request with 400 `invalid_request`; every other shape error is a per-mutation `validation_failed` (§14.4.4 step 3). D-036: `POST /sync` with 0 mutations is 422 `batch_too_large`, the code now reads "outside 1 to 100" (§9.6, §14.3.2, §14.4.4). D-037: a delete of a never-synced row runs the full step-8 validation; every rejection carries the stored row or null as `current`, also at steps 3 and 4; step 11 keeps the stored `created_at`; pushed ids must be canonical lowercase hyphenated UUIDs (§14.4.3, §14.4.4). D-038: FCM retries are one send plus up to three retries at 1 s, 5 s, 25 s; 401 drops the cached access token and is not retried, other 4xx are not retried; retries block the single sender task up to 31 s per device (§14.6). D-039: pruning deletes `received_at` strictly older than 90 days; seam change to D-015: `tasks::start` returns the `AppState` with the real push queue and `serve` builds the router from it, `AppState::new` defaults to `SqlAuditWriter`, `NoopAuditWriter` removed, `NoopPushQueue` kept for push disabled (§6.3, §14.1, §14.4.9; PLAN-M4). Clarifications without a decision id: §14.4.4 step 8 order is field rules, domain rules, references; a negative `since` is `invalid_request` and a pull reads one page in a single read transaction (§9.6, §14.3.4, §14.4.2); push runs in one `BEGIN IMMEDIATE` with a savepoint per mutation; the push queue is bounded at 100 and drops the oldest with a warning; the FCM JWT is RS256 with `kid` = `private_key_id`, the access token cached until 5 minutes before expiry, messages data-only with string values; `UNREGISTERED` or 404 clears the device token only if unchanged; an unusable credentials file disables push like a missing one (§6.3, §14.6); the scheduler reads the injected clock at least every 30 s, runs a missed job once after a forward jump and re-plans after a backward jump of more than a day (§9.5); `rotate-epoch` prints the new epoch, exit 0 or 1 on a database error (§10.4); `serve` uses `into_make_service_with_connect_info` (D-032 fulfilled, §8.6, §11.3); `GET /i/` with an empty code is the HTML 404 (E-4); reqwest (rustls, aws-lc-rs, platform verifier) and jsonwebtoken added, so the arm64 image needs a C toolchain stage and the distroless image CA roots, to be confirmed by M2c-pi or M10; the dev image was rebuilt with them on the Mac on 2026-10-18 (§6.3, §10.3); M4's test binaries and scheduler path (§11.3, §14.7). | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 1289767..fb84d4c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,38 +3,31 @@ Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this. ## 1. Where things stand -- `main` at `c28c52b` (M5) plus the SPEC v0.5 docs commit from `agent-spec5` once it lands. History: `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `fad6fc9` M2b, `fdf2093` M2a, `040bf41` SPEC v0.4, `503cee5` M3, `c28c52b` M5. -- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2a server skeleton and `kvitt-domain` (`fdf2093`), M2b Android skeleton and `:domain` (`fad6fc9`), M2c-dev infra for development (`c885aba`), SPEC v0.4 (`040bf41`), M3 server core API (`503cee5`), M5 Android data layer (`c28c52b`). With `agent-spec5`: SPEC v0.5 with M3's D-030 to D-034 and M5's D-026 to D-029 and D-040 to D-043 (M5's proposals D-030 to D-033 renumbered to D-040 to D-043; D-035 to D-039 reserved for M4). +- `main` at `bd6f10a` (M4) plus the SPEC v0.6 docs commit from `agent-spec6` once it lands. History: `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `fad6fc9` M2b, `fdf2093` M2a, `040bf41` SPEC v0.4, `503cee5` M3, `c28c52b` M5, `982451d` SPEC v0.5, `bd6f10a` M4. +- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2a server skeleton and `kvitt-domain` (`fdf2093`), M2b Android skeleton and `:domain` (`fad6fc9`), M2c-dev infra for development (`c885aba`), M3 server core API (`503cee5`), M5 Android data layer (`c28c52b`), SPEC v0.5 (`982451d`), M4 server sync, audit, push, scheduler (`bd6f10a`). With `agent-spec6`: SPEC v0.6 with M4's D-035 to D-039. - Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy -- -D warnings && cargo test` (from `server/`). -- Worktrees in flight under `.claude/worktrees/`: `agent-m4` (M4 server sync, audit, push, scheduler), `agent-spec5` (this docs change: SPEC v0.5). -- The owner runs the UX sprint (U1, then U2) in a separate Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`; it commits only under `docs/ux/` and rebases on `main` before landing. -- Development stack (M2c-dev): running on the Mac, reachable at `http://192.168.1.210:8080`; the owner confirmed `/health` from the phone on the home Wi-Fi on 2026-10-17. Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`; `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` in the main checkout (git-ignored). -- Installed on the owner's phone: nothing yet (M2b's `installDebug` is pending). +- Worktrees in flight under `.claude/worktrees/`: `agent-m9` (M9 Splitwise importer; decisions D-044 to D-049 reserved for it), `agent-spec6` (this docs change: SPEC v0.6). +- The owner runs the UX sprint in a separate Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`; it commits only under `docs/ux/` and rebases on `main` before landing. U1 is committed on `UX-sprint` at `210ab5c` (`docs/ux/`: directions, ia, screens, flows, components, tokens, `strings.csv`); the owner has not yet picked a direction. +- Development stack (M2c-dev): running on the Mac with the M4 image, reachable at `http://192.168.1.210:8080`; the owner confirmed `/health` from the phone on the home Wi-Fi on 2026-10-17. The dev image was rebuilt with M4's new crates (reqwest on rustls/aws-lc-rs, jsonwebtoken) on 2026-10-18 and built cleanly. Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`; `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` in the main checkout (git-ignored). +- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug` built from `main` at `c28c52b`, as of 2026-10-18 (M2b's `installDebug` check is done). ## 2. Open work (one line each, with who owns it: agent or owner) -- Land `agent-spec5` (orchestrator): SPEC v0.5, decisions D-026 to D-034 and D-040 to D-043, PLAN-M8 (M5's contract requests, D-027 follow-up), PLAN-M6 (picture size note), PLAN-M4 (`into_make_service_with_connect_info`), prompts/README status. -- Tell `agent-m4` before it lands (orchestrator): D-035 to D-039 are its range; v0.5 asks M4 to add `into_make_service_with_connect_info::()` to `serve` (D-032, §11.3); M3's tests are in `tests/core_api/`; generation sets the template's `updated_at` (§9.5); D-033 resume rule. +- Land `agent-spec6` (orchestrator): SPEC v0.6, decisions D-035 to D-039, PLAN-M4 pruning wording, prompts/README status. +- Image build for the Pi (M2c-pi or M10): since M4 the arm64 server image needs a C toolchain in the build stage (aws-lc-rs) and CA roots in the distroless image (§10.3); confirm both and one FCM send from the shipped image. - D-027 re-stamp (orchestrator assigns: M8 as a contract request on `:data`, or a small `:data` agent): 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. Not in `:data` at `c28c52b`. - Picture size (M6, if needed): `:data` always fetches E-11 at 512; a size parameter for the 128 variant is a contract request on `:core:model` and `:data`. -- `server/README.md` (server-owned, next server agent): still says M3's tests go in `tests/core_*.rs`; v0.5 §14.7 records the single binary `tests/core_api/`. -- Plans (SPEC §11.2 v0.5), in dependency order; prompt `prompts/.md`: - - M1 Contracts and vectors: landed (`c6df2ab`). - - M2a Server skeleton and `kvitt-domain`: landed (`fdf2093`). - - M2b Android skeleton and `:domain`: landed (`fad6fc9`); outstanding: `installDebug` on the phone (owner plugs it in). - - M2c-dev Infra for development: landed (`c885aba`); phone `/health` check done (owner, 2026-10-17). - - U1 UX directions: owner's session (`UX-sprint`), in progress; owner then picks a direction. - - U2 UX design spec: owner's session, after U1 and the pick; owner approves; then the §9.7 colour change goes into the spec (docs agent) before M6. - - M3 Server core API: landed (`503cee5`). - - M4 Server sync, audit, push, scheduler: agent in flight (`agent-m4`); lands after M3 (done). - - M5 Android data layer: landed (`c28c52b`). - - Next waves: M9 Splitwise importer after M4; M6 Design system after U2 approved and the §9.7 colour change; M7a to M7f Features after M6, in parallel; M8 App integration after M7 (and M3, M4, M5). +- Plans (SPEC §11.2 v0.6), in dependency order; prompt `prompts/.md`: + - M1, M2a, M2b, M2c-dev, M3, M4, M5: landed. + - U1 UX directions: committed on `UX-sprint` (`210ab5c`); waiting for the owner's pick. + - U2 UX design spec: owner's session, after the pick; owner approves; then the §9.7 colour change goes into the spec (docs agent) before M6. + - M9 Splitwise importer: agent in flight (`agent-m9`). + - Next waves: M6 Design system after U2 approved and the §9.7 colour change; M7a to M7f Features after M6, in parallel; M8 App integration after M7 (and M3, M4, M5). - M2c-pi Infra on the Pi: after M8, before M10 (owner: answer OQ-3, grant the Pi, Home Assistant Pi, Gitea and Drive allowances at the start, D-023). - M10 First release: after M2c-pi, M3, M4, M8 (owner: keystore per §10.6, D-024). - M11 Import history: after M9 and M10. ## 3. HUMAN steps pending (relay in chat) -- Plug the phone in over USB so an agent can run `installDebug` of the debug app (expected: the Kvitt debug app opens to an empty themed screen). -- UX direction pick from U1's directions, with comments (owner's UX-sprint session). +- UX direction pick from U1's directions in `docs/ux/` on `UX-sprint`, with comments (owner's UX-sprint session). ## 4. Owner questions (asked, unanswered) - OQ-3 LAN addresses and SSH users of the Kvitt Pi and the Home Assistant Pi; SSH port of the HA Pi; certificate directory there; which SSH add-on (SPEC §12). Needed by M2c-pi only. The only open question (OQ-1, OQ-2, OQ-4 answered: D-001, D-003, §10.9). @@ -43,9 +36,9 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - Nothing. ## 6. Numbering -- Decisions: D-001 to D-034 and D-040 to D-043 used (D-008 and D-009 unused). Reserved: **D-035 to D-039 for M4** (in flight). Next free for anything else: **D-044**. -- Spec version: v0.5 written; next **v0.6**. -- Server schema version **1**; next **2**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`, unchanged by M5); next **2**. +- Decisions: D-001 to D-043 used (D-008 and D-009 unused). Reserved: **D-044 to D-049 for M9** (in flight). Next free for anything else: **D-050**. +- Spec version: v0.6 written; next **v0.7**. +- Server schema version **1**; next **2**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. - Sync protocol **1**, push payload **1**. ## 7. How the work is run diff --git a/docs/decisions.md b/docs/decisions.md index 14fce20..e485642 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -39,11 +39,11 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | 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-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 | diff --git a/prompts/README.md b/prompts/README.md index bc2b100..630aa31 100644 --- a/prompts/README.md +++ b/prompts/README.md @@ -2,7 +2,7 @@ One ready-to-paste start prompt per plan (`PLAN-.md` at the repository root), plus `ORCHESTRATOR-prompt.md`. Before pasting, the orchestrator fills `{{NEXT_DECISION}}`, `{{SPEC_VERSION}}`, `{{SCHEMA_VERSION}}` (disjoint ranges for agents that run in parallel, CLAUDE.md › Numbering) and `{{ORCHESTRATOR_NOTES}}` (answers to the plan's open points, phone assignment, overrides). `U2.md` also takes `{{OWNER_PICK_AND_COMMENTS}}`. U1 and U2 run in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`. The numbers are for proposed entries in agents' reports: implementation agents never edit `SPEC.md` or `docs/decisions.md`; a docs agent records what the orchestrator accepts (CLAUDE.md › Orchestration). -Dependencies follow SPEC §11.1 and §11.2 (v0.5). Status as of 2026-10-17: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`fdf2093`), M2b landed (`fad6fc9`), M3 landed (`503cee5`), M5 landed (`c28c52b`); M4 in flight; U1 in the owner's session. The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043; D-035 to D-039 reserved for M4). +Dependencies follow SPEC §11.1 and §11.2 (v0.6). Status as of 2026-10-18: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`fdf2093`), M2b landed (`fad6fc9`), M3 landed (`503cee5`), M5 landed (`c28c52b`), M4 landed (`bd6f10a`); M9 in flight; U1 committed on `UX-sprint` (owner's direction pick pending). The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043); v0.6 the M4 decisions (D-035 to D-039). ## Dependency order and waves @@ -17,7 +17,7 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7 | 1 | M2c-dev Infra for development | `M2c-dev.md` | M0 (its health check needs M2a's image) | done (`c885aba`) | | 1 | U2 UX design spec | `U2.md` | U1 and the owner's pick | M2a, M2b, M2c-dev, wave 2 | | 2 | M3 Server core API | `M3.md` | M2a | done (`503cee5`) | -| 2 | M4 Server sync, audit, push, scheduler | `M4.md` | M2a; lands after M3 (D-015) | M3, M5, M6 | +| 2 | M4 Server sync, audit, push, scheduler | `M4.md` | M2a; lands after M3 (D-015) | done (`bd6f10a`) | | 2 | M5 Android data layer | `M5.md` | M1, M2b | done (`c28c52b`) | | 2 | M6 Design system | `M6.md` | U2 approved, M2b, the §9.7 colour spec change | M3, M4, M5 | | 3 | M7a Feature onboarding | `M7a.md` | M6 | M7b to M7f, M9 |