SPEC v0.17: D-103 to D-106 Pi bundle, D-107 S-19 option rows, D-108 instant navigation; HANDOFF, prompts

D-103 the infra/pi/ bundle (compose.yaml, .env.example, Caddyfile, install,
deploy, bootstrap, backup-now, install-apk; APK path via infra/pi/out/apk/;
new .env keys; gate docker compose -f infra/pi/compose.yaml config).
D-104 image names gitea.lan/jacobh2/kvitt-*, the Gitea workflow, Mac
fallback, supercronic, pinned versions. D-105 backup uid 10001 and
rclone.conf ownership (revises D-023 for that file). D-106 cert-sync tar
stream and refusals, backup confirm-then-delete. D-107 S-19 option rows.
D-108 instant navigation (design-spec §2.11). Chips cleanup and the
Dockerfile CARGO_BUILD_JOBS argument recorded without an id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 21:56:48 +02:00
parent 1328afe4f9
commit 400eac9d60
5 changed files with 110 additions and 77 deletions
+65 -49
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.16** (2026-10-18). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
Version **0.17** (2026-10-18). Schema version **3**. 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.
@@ -859,50 +859,56 @@ Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expe
All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `infra/secrets/` on the Mac (git-ignored) and in `/opt/kvitt/secrets/` on the Pi; neither is ever committed.
**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts the whole `infra/secrets/` folder read-only at `/secrets` instead of a single key file (a missing `fcm-service-account.json` means push is disabled, §10.4); its env file is `infra/dev/.env.example`; its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env template is part of the M2c-pi bundle (D-100). The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup, and to schema 3 (migration 0003, §14.1.2) on 2026-10-18 when it was rebuilt from `90505a8`, again after a backup. On the Mac's Podman setup `infra/dev/up.sh` alone does not swap in a rebuilt image: run `infra/dev/up.sh --down`, then `infra/dev/up.sh`. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10.
**Development first (D-001).** During development the server runs locally on the owner's M4 Mac (Docker compose from `infra/dev/compose.yaml`, or plain `cargo run` in `server/`), and debug builds pair against it over the home Wi-Fi (debug builds accept `http://` to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed `c885aba`, D-022) has the `server` service and the `backup` job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain `http://`, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (`infra/dev/data/`, git-ignored, and `infra/dev/backups/` for local snapshots); it mounts the whole `infra/secrets/` folder read-only at `/secrets` instead of a single key file (a missing `fcm-service-account.json` means push is disabled, §10.4); its env file is `infra/dev/.env.example`; its `server` healthcheck is `kvitt-server healthcheck`; its `backup` runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env template is `infra/pi/.env.example` (D-103). The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup, and to schema 3 (migration 0003, §14.1.2) on 2026-10-18 when it was rebuilt from `90505a8`, again after a backup. On the Mac's Podman setup `infra/dev/up.sh` alone does not swap in a rebuilt image: run `infra/dev/up.sh --down`, then `infra/dev/up.sh`. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10.
**No SSH from the Mac to the Pis (D-100, revises D-023).** M2c-pi delivers a self-contained bundle in `infra/pi/`: the compose file, the Caddyfile, an env template, install, deploy, bootstrap and backup scripts and a README checklist. The owner copies the bundle to the Kvitt Pi, fills the placeholders in its `.env` (the OQ-3 values among them) and runs the scripts there. Agents never connect to the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive and need no allowance for them; they check the bundle on the Mac (`docker compose config` with the env template, `shellcheck`). Where §10.2 to §10.10 have a script on the Mac act on the Pi (for example `infra/deploy.sh` and `infra/publish-apk.sh` over SSH), that step becomes a bundle script the owner runs on the Pi; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. D-001 stands: development stays on the Mac.
**No SSH from the Mac to the Pis (D-100, revises D-023).** M2c-pi delivers a self-contained bundle in `infra/pi/`: the compose file, the Caddyfile, an env template, install, deploy, bootstrap and backup scripts and a README checklist. The owner copies the bundle to the Kvitt Pi, fills the placeholders in its `.env` (the OQ-3 values among them) and runs the scripts there. Agents never connect to the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive and need no allowance for them; they check the bundle on the Mac (`docker compose config` with the env template, `shellcheck`). Where §10.2 to §10.10 have a script on the Mac act on the Pi (for example `infra/deploy.sh` and `infra/publish-apk.sh` over SSH), that step becomes a bundle script the owner runs on the Pi; M2c-pi fixes the layout inside `infra/pi/`, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. D-001 stands: development stays on the Mac. The bundle landed on 2026-10-18 (`5e027df`); its layout, scripts and paths are D-103 to D-106 and are written into §10.2 to §10.10 below; the owner's checklist is `infra/README.md` "Pi: what the owner does" (nine steps).
### 10.1 Hosts and network
| Host | Role | Facts |
|---|---|---|
| Kvitt Pi | Runs the compose stack | Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin, static LAN address. LAN address: a placeholder in the bundle's `.env`, filled by the owner (OQ-3, D-100); the Mac does not reach it over SSH |
| Kvitt Pi | Runs the compose stack | Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin (from Docker's Debian arm64 apt repository, D-104), static LAN address. LAN address: a placeholder in the bundle's `.env`, filled by the owner (OQ-3, D-100); the Mac does not reach it over SSH |
| Home Assistant Pi | Owns ports 80/443 and the Let's Encrypt certificate (certbot) for `icehack.asuscomm.com` | Reached by cert-sync (on the Kvitt Pi) over SSH. Address, SSH port and user and certificate directory: placeholders in the bundle's `.env` (`HA_HOST`, `HA_PORT`, `HA_USER`, `HA_CERT_DIR`), filled by the owner (OQ-3, D-100) |
| Owner's M4 Mac | Builds images and APKs; runs the development server (D-001) | Apple Silicon, so `linux/arm64` builds run natively in Docker |
| Owner's M4 Mac | Builds release APKs (`infra/publish-apk.sh`); builds images only as the fallback of the Gitea workflow (`infra/build-images.sh`, D-104); runs the development server (D-001) | Apple Silicon, so `linux/arm64` builds run natively in Docker |
| Router (ASUS) | `icehack.asuscomm.com` DDNS | Forwards external TCP 8443 to Kvitt Pi TCP 8443 (HUMAN step) |
| Container registry | Holds Kvitt images | A Gitea instance on another Pi at `http://gitea.lan`: plain HTTP, reachable only on the home Wi-Fi (D-001). If Gitea requires credentials for push or pull, the owner enters them where the bundle's checklist says (HUMAN, D-100); agents never hold them |
| Container registry | Holds Kvitt images | A Gitea instance on another Pi at `http://gitea.lan`: plain HTTP, reachable only on the home Wi-Fi (D-001). Kvitt images live under the owner path `gitea.lan/jacobh2` (D-104); Gitea Actions builds them (§10.3). If Gitea requires credentials for push or pull, the owner enters them where the bundle's checklist says (HUMAN, D-100); agents never hold them |
The Pi never builds anything. It only pulls images and runs them.
The Kvitt Pi's compose stack never builds anything. It only pulls images and runs them.
### 10.2 Compose stack
File `infra/compose.yaml`, deployed to `/opt/kvitt/compose.yaml` on the Pi with `/opt/kvitt/.env` (from `infra/env.example`). Services:
File `infra/pi/compose.yaml` (compose project name `kvitt`), installed by `infra/pi/install.sh` to `/opt/kvitt/compose.yaml` on the Pi together with `/opt/kvitt/Caddyfile` and `/opt/kvitt/.env` (mode 0600, from `infra/pi/.env.example`) (D-103; was `infra/compose.yaml` and `infra/env.example`). Services:
| Service | Image | Ports / volumes | Memory limit | Healthcheck |
|---|---|---|---|---|
| `caddy` | official `caddy` image, pinned by version and digest | publishes `8443:8443`; `./Caddyfile:/etc/caddy/Caddyfile:ro`; `certs:/certs:ro`; `caddy_data:/data` | 64 MiB | `wget -qO- http://localhost:2019/config/ >/dev/null` |
| `server` | `${KVITT_REGISTRY}/kvitt/server:${KVITT_TAG}` | `data:/data`; `/opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro`; `/opt/kvitt/import:/import:ro` (empty except during the one-off import, §10.8, D-025); no published port (listens on 8080 internally) | 160 MiB (budget: RSS under 100 MB in steady state) | `GET http://localhost:8080/health` via the binary's own `kvitt-server healthcheck` subcommand (no curl in the image) |
| `cert-sync` | `${KVITT_REGISTRY}/kvitt/cert-sync:${KVITT_TAG}` | `certs:/certs`; `./Caddyfile:/etc/caddy/Caddyfile:ro`; `/opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro`; `/opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro` | 32 MiB | both cert files exist and are non-empty |
| `backup` | `${KVITT_REGISTRY}/kvitt/backup:${KVITT_TAG}` | `data:/data`; `backups:/backups`; `/opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro` | 96 MiB | last successful run marker newer than 26 hours, or no run yet since start |
| `caddy` | official `caddy` image, pinned by version and digest: `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af` (D-104) | publishes `8443:8443`; `./Caddyfile:/etc/caddy/Caddyfile:ro`; `certs:/certs:ro`; `caddy_data:/data` | 64 MiB | `wget -qO- http://localhost:2019/config/ >/dev/null` |
| `server` | `${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}` | `data:/data`; `/opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro`; `/opt/kvitt/import:/import:ro` (empty except during the one-off import, §10.8, D-025); no published port (listens on 8080 internally) | 160 MiB (budget: RSS under 100 MB in steady state) | `GET http://localhost:8080/health` via the binary's own `kvitt-server healthcheck` subcommand (no curl in the image) |
| `cert-sync` | `${KVITT_REGISTRY}/kvitt-cert-sync:${KVITT_TAG}` | `certs:/certs`; `./Caddyfile:/etc/caddy/Caddyfile:ro`; `/opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro`; `/opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro` | 32 MiB | both cert files exist and are non-empty |
| `backup` | `${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG}` | `data:/data`; `backups:/backups`; `/opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro` | 96 MiB | last successful run marker newer than 26 hours, or no run yet since start |
* Named volumes: `data` (SQLite, pictures, APKs), `certs`, `caddy_data`, `backups`.
* `caddy` `depends_on` `cert-sync` with `condition: service_healthy` and `server` with `condition: service_started`.
* Every service: `restart: unless-stopped`, logging driver `json-file` with `max-size: 10m`, `max-file: 3`, `TZ=Europe/Stockholm`.
* One internal bridge network `kvitt`.
* The `cert-sync` and `backup` containers run as uid 1000. Their secret files on the Pi (`/opt/kvitt/secrets/ha_ssh_key`, `ha_known_hosts`, `rclone.conf`) have mode 0600 and are owned by uid 1000 (D-023). The FCM key keeps the mode of §10.7. The development stack runs `backup` as uid 10001 instead, because opening the WAL database needs access to the files the server owns; M2c-pi reconciles the two (uid 1000 with access to the `data` volume, or uid 10001 with the secrets owned to match) and records the choice in `infra/README.md`.
* The `cert-sync` container runs as uid 1000; its secret files on the Pi (`/opt/kvitt/secrets/ha_ssh_key`, `ha_known_hosts`) have mode 0600 and are owned by 1000:1000 (D-023). The `backup` container runs as uid 10001, the server's uid, on the Pi as on the development stack, because `sqlite3 .backup` on the WAL database must open the `-shm` file the server owns; so `/opt/kvitt/secrets/rclone.conf` has mode 0600 and is owned by 10001:10001 (D-105, revises D-023 for this file). rclone writes refreshed access tokens back to its config, and the mounted file is read-only, so `backup.sh` works on a temporary copy of it on every run (D-105). `install.sh` gives the named volumes their owners once (`/data`, `/data/apk`, `/backups` to 10001, `/certs` to 1000). The FCM key keeps the mode of §10.7 (0440, root:10001).
`.env` keys (exhaustive): `KVITT_REGISTRY`, `KVITT_TAG`, `HA_HOST`, `HA_PORT`, `HA_USER`, `HA_CERT_DIR`, `RCLONE_REMOTE` (default `gdrive:`), `RCLONE_FOLDER_ID`.
`.env` keys (exhaustive, D-103). Read by `compose.yaml`: `KVITT_REGISTRY` (`gitea.lan/jacobh2`), `KVITT_TAG`, `HA_HOST`, `HA_PORT`, `HA_USER`, `HA_CERT_DIR`, `RCLONE_REMOTE` (default `gdrive:`), `RCLONE_FOLDER_ID`. Read by `install.sh` only, never by the containers: `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (empty for anonymous pull). Informational, used in the owner's `scp` commands on the Mac and read by nothing on the Pi: `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. Unfilled values are `CHANGE_ME` placeholders; `install.sh` refuses a `.env` that still contains one.
### 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.
* Image names (D-104): `${KVITT_REGISTRY}/kvitt-server`, `${KVITT_REGISTRY}/kvitt-cert-sync`, `${KVITT_REGISTRY}/kvitt-backup`, with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/<name>`).
* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host` (an act_runner on a Raspberry Pi 3 with 1 GB RAM; the builds run on that Pi itself), `sh` steps, Alpine `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with the repository secret `kvitt_cicd` (created by the owner), a plain-HTTP buildx builder `local-http-builder` (`buildkitd.toml` with `http = true` for `gitea.lan`) created once and reused so its BuildKit cache survives, `actions/checkout@v4` with `fetch-depth: 0`. Triggers: a push touching `server/**`, `infra/cert-sync/**`, `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch`. Version `v<MAJOR>.<MINOR>.<PATCH>` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is what the deploy script takes; it says nothing about the APK version (§10.6). The Pi runner passes `--build-arg CARGO_BUILD_JOBS=1` to the server build (Pi 3, 1 GB), and `server/Dockerfile` honours it (`ARG CARGO_BUILD_JOBS` in its build stage, passed to cargo as `--jobs` only when set; unset on the Mac means one job per core; `965bb5d`). The workflow has not run yet; the first build's duration on the Pi is unknown.
* Script `infra/build-images.sh [tag]` on the Mac is the fallback (D-104): builds the three images for `linux/arm64`, tag defaults to the short git SHA of `HEAD`, also tags `latest`; pushes when `infra/secrets/registry.env` (git-ignored: `KVITT_REGISTRY`, optional `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN`) exists, otherwise builds locally and prints the exact push commands. Refuses to run with a dirty working tree or while a Gradle build holds the `tools/gradle-serial.sh` lock. Works with Docker buildx and with Podman.
* `server` image: multi-stage. Stage 1 `rust:<version>-bookworm`, where `<version>` 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. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (`docs/spikes/pi-image-arm64.md`, landed `1e404cc`): arm64 image 63.8 MB, cold build 48 s, CA bundle from distroless, binaries run natively; running on the Pi itself is M2c-pi's.
* `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 <tag>` 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`.
* The Pi's Docker daemon must trust `gitea.lan` as an insecure (plain HTTP) registry (`insecure-registries`). Adding it is its own HUMAN step (sudo on the Pi); M2c-pi writes the exact step.
* Later, images are built by Gitea Actions on a Pi from an example workflow the owner provides (`TODO(spec)` until provided, D-001). The workflow file lives in `.gitea/workflows/` at the repository root, owned by M2c-pi (D-023). Until then `infra/build-images.sh` on the Mac pushes to `gitea.lan`.
* `cert-sync` image: `alpine` with `openssh-client-default`, `curl`, `openssl` (PEM and key checks, D-104) and `tzdata`, and the shell script `infra/cert-sync/sync.sh`, run once at start by `infra/cert-sync/entrypoint.sh` and then daily by supercronic.
* `backup` image: `alpine` with `sqlite`, `gzip`, `tar`, `rclone` and `tzdata`, and `infra/backup/backup.sh` run by supercronic; the healthcheck is `infra/backup/healthcheck.sh`.
* Both sidecars schedule with supercronic instead of busybox `crond` (D-104): they run as non-root users, and busybox `crond` started as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedules are unchanged (`0 4 * * *` cert-sync, `30 3 * * *` backup).
* Pinned versions (resolved 2026-10-18, D-104; table in `infra/README.md` "Versions"): Caddy `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`; Alpine `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6` for both sidecars; cert-sync packages openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; backup packages supercronic 0.2.49 beside M2c-dev's sqlite, rclone and tar. Docker Engine on the Kvitt Pi comes from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is for 32-bit only), `docker-ce` 5:29.8.2 with `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin` and `docker-compose-plugin`.
* Bundle scripts (D-103), run by the owner on the Kvitt Pi (D-100), with shared helpers in `infra/pi/lib.sh`:
* `infra/pi/install.sh`, run from the copied bundle, idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secret files with the modes and owners of §10.2 and §10.7, adds `gitea.lan` to `insecure-registries` in `/etc/docker/daemon.json` (restarting Docker when that changed) and logs in to the registry when `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN` are set, copies `compose.yaml`, `Caddyfile`, `.env` and the scripts to `/opt/kvitt`, sets the volume owners, pulls, starts the stack and waits for it. A newer bundle is installed by copying it again and re-running `install.sh`.
* `/opt/kvitt/deploy.sh <tag> [--skip-backup]`: runs `backup-now.sh` first on every deploy, because the Pi cannot tell whether a tag carries a migration (skipped when no database exists yet, or with `--skip-backup`, for a rollback while Drive is down); keeps the current tag in `/opt/kvitt/previous-tag` for rollback; sets `KVITT_TAG` in `/opt/kvitt/.env`, runs `docker compose pull && docker compose up -d`, waits for `server` to be healthy, prints `GET /health`.
* `/opt/kvitt/bootstrap.sh` (first install: the admin invite of FR-1), `/opt/kvitt/backup-now.sh` (one backup run, succeeds only when `last-success` changed, §10.9), `/opt/kvitt/install-apk.sh` (§10.6).
* The Pi's Docker daemon must trust `gitea.lan` as an insecure (plain HTTP) registry (`insecure-registries`). `install.sh` adds it with sudo, run by the owner (D-103).
### 10.4 Server configuration and CLI
@@ -944,13 +950,15 @@ Run them on the Pi with `docker compose exec server kvitt-server <command>` (or
#### 10.5.1 Certificate sync
* The Home Assistant Pi renews the Let's Encrypt certificate for `icehack.asuscomm.com` (its own certbot; Kvitt does not touch it).
* `cert-sync` runs `sync.sh` at container start and daily at 04:00 Europe/Stockholm. It connects to `${HA_USER}@${HA_HOST}:${HA_PORT}` with the key `/secrets/ha_ssh_key` and `StrictHostKeyChecking=yes` against `/secrets/known_hosts`, and copies `${HA_CERT_DIR}/fullchain.pem` and `${HA_CERT_DIR}/privkey.pem` into a temp directory.
* The key is dedicated to Kvitt and, where the Home Assistant SSH setup allows `authorized_keys` options, restricted with `restrict,command="tar -C <cert dir> -cf - fullchain.pem privkey.pem"` (the script then reads a tar stream instead of using `scp`). The infra plan records which variant the Home Assistant SSH add-on supports.
* If both files parse as PEM, the certificate's not-after is in the future, and the SHA-256 of either differs from `/certs`, the script moves them into `/certs` (mode 0640, group readable by Caddy) and reloads Caddy: `curl -fsS -X POST http://caddy:2019/load -H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @/etc/caddy/Caddyfile`.
* `cert-sync` runs `sync.sh` at container start and daily at 04:00 Europe/Stockholm. It connects to `${HA_USER}@${HA_HOST}:${HA_PORT}` with the key `/secrets/ha_ssh_key` and `StrictHostKeyChecking=yes` against `/secrets/known_hosts`, always requests the remote command `tar -C ${HA_CERT_DIR} -cf - fullchain.pem privkey.pem` and unpacks the tar stream into a temp directory (D-106).
* The key is dedicated to Kvitt (`infra/secrets/ha_ssh_key` on the Mac, git-ignored). Where the Home Assistant SSH add-on allows `authorized_keys` options it is restricted with `restrict,command="tar -C <cert dir> -cf - fullchain.pem privkey.pem"` (variant A); otherwise it is added as a plain key (variant B). Because the script always requests that exact command, both variants work unchanged (D-106). Which variant the add-on accepts is reported by the owner at checklist step 3 and recorded in `infra/README.md`.
* If both files parse as PEM, the certificate's not-after is in the future, the private key matches the certificate, the certificate does not expire before the installed one, and the SHA-256 of either differs from `/certs`, the script moves them into `/certs` (mode 0640, group readable by Caddy) and reloads Caddy: `curl -fsS -X POST http://caddy:2019/load -H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @/etc/caddy/Caddyfile`. A key not matching its certificate, or a certificate expiring before the installed one, is refused and the installed pair is kept (D-106). At first start Caddy is not up yet (it waits for `cert-sync` to be healthy) and loads the files when it starts, so an unreachable Caddy admin API is logged and is not a failure (D-106).
* Any failure logs one line and keeps the existing certificate. The healthcheck stays green while valid files exist.
#### 10.5.2 Caddyfile
File `infra/pi/Caddyfile` (D-103), installed as `/opt/kvitt/Caddyfile`:
```
{
admin 0.0.0.0:2019
@@ -970,7 +978,7 @@ icehack.asuscomm.com:8443 {
}
```
Caddy logs must not include the `Authorization` header (Caddy redacts it by default; the infra plan verifies).
Caddy logs must not include the `Authorization` header (Caddy redacts it by default). Verified on the Mac on 2026-10-18 with the pinned image: the header is logged as `"Authorization":["REDACTED"]` and the token never appears; TLS 1.1 is refused.
#### 10.5.3 Fallback: Caddy internal CA
@@ -983,7 +991,7 @@ Used only if the certificate sync cannot be made to work. Documented in `infra/R
### 10.6 APK hosting and release
* `version_code` = `git rev-list --count HEAD` on the commit being released; `version_name` = `YYYY.MM.DD-<short sha>` of that commit. Both are passed to Gradle as properties `kvitt.versionCode` and `kvitt.versionName` by the release script; debug builds use `1` and `dev`.
* `version_code` = `git rev-list --count HEAD` on the commit being released; `version_name` = `YYYY.MM.DD-<short sha>` of that commit (its commit date in Europe/Stockholm). Both are passed to Gradle as properties `kvitt.versionCode` and `kvitt.versionName` by the release script; debug builds use `1` and `dev`.
* The release keystore is created once on the Mac (by the owner, or by an agent the owner allows; §11, D-024, D-101) at the root of the main checkout as `kvitt-release.jks`, with exactly:
```
@@ -991,8 +999,8 @@ Used only if the certificate sync cannot be made to work. Documented in `infra/R
```
The file is git-ignored by the `*.jks` pattern and never committed (D-101, revises D-024's "outside the repository"). `android/local.properties` holds its absolute path and secrets in the keys `kvitt.release.storeFile`, `kvitt.release.storePassword`, `kvitt.release.keyAlias` (`kvitt`), `kvitt.release.keyPassword`. Losing it means the phones must uninstall and reinstall (data comes back by sync). The owner keeps a copy of the file and its password in their password manager. Without these keys in `local.properties`, `:app:assembleRelease` builds an unsigned APK (M8). Release signing was verified on 2026-10-18: alias `kvitt`, RSA 4096, APK signature scheme v2.
* Release builds are R8-shrunk, optimised and obfuscated (`isMinifyEnabled`, `isShrinkResources`, `proguard-android-optimize.txt` plus `android/app/proguard-rules.pro`, no project keep rules needed as of 2026-10-18; landed `57f984f`); the signed APK is about 11 MB (was 43 MB); `infra/publish-apk.sh` saves `mapping.txt` next to the APK as `apk/kvitt-<version_code>.mapping.txt` for retrace (the script is still to be written; under D-100 M2c-pi decides where it lives).
* `infra/publish-apk.sh` on the Mac: builds `:app:assembleRelease` through `tools/gradle-serial.sh`, computes SHA-256 and size, copies the APK to the Pi's `data` volume as `apk/kvitt-<version_code>.apk`, writes `apk/latest.json` atomically (write temp, rename):
* Release builds are R8-shrunk, optimised and obfuscated (`isMinifyEnabled`, `isShrinkResources`, `proguard-android-optimize.txt` plus `android/app/proguard-rules.pro`, no project keep rules needed as of 2026-10-18; landed `57f984f`); the signed APK is about 11 MB (was 43 MB); `infra/publish-apk.sh` saves `mapping.txt` next to the APK as `kvitt-<version_code>.mapping.txt` for retrace; mappings are never served and stay outside the `data` volume (D-103).
* `infra/publish-apk.sh` on the Mac (D-103): refuses a dirty working tree, builds `:app:assembleRelease` through `tools/gradle-serial.sh` with `-Pkvitt.versionCode` and `-Pkvitt.versionName`, refuses an unsigned APK, computes SHA-256 and size, and writes into `infra/pi/out/apk/` (git-ignored) `kvitt-<version_code>.apk`, `kvitt-<version_code>.mapping.txt` and `latest.json` (written to a temp file, then renamed), keeping the newest 5 APKs with their mappings there. The owner copies the folder to `/opt/kvitt/apk/` on the Pi (`scp`) and runs `/opt/kvitt/install-apk.sh`, which checks each SHA-256 against `latest.json`, copies the APKs into the `data` volume as `apk/kvitt-<version_code>.apk` and writes `apk/latest.json` last (temp file, rename), so the server never announces a file it does not have; the mapping files stay in `/opt/kvitt/apk/`. `latest.json`:
```json
{
@@ -1006,7 +1014,7 @@ Used only if the certificate sync cannot be made to work. Documented in `infra/R
```
* The server reads `latest.json` on each `GET /apk/latest` (no cache) and serves `GET /apk/files/{file_name}` only for a `file_name` matching `^kvitt-[0-9]+\.apk$` that exists, with `Content-Type: application/vnd.android.package-archive`.
* Older APKs stay on the Pi; the publish script keeps the newest 5 and deletes older ones.
* Older APKs stay on the Pi; `install-apk.sh` keeps the newest 5 in the `data` volume and deletes older ones (D-103).
### 10.7 Firebase setup (M2c-dev, with a HUMAN sign-in)
@@ -1018,12 +1026,12 @@ No new command-line tool is installed for this (D-022): `gcloud` exists on the M
4. Create two Android apps (`projects.androidApps.create`): display name "Kvitt", package `se.hagstedt.kvitt`; display name "Kvitt debug", package `se.hagstedt.kvitt.debug`.
5. Fetch each app's config (`projects.androidApps.getConfig`) and merge them into one `google-services.json` holding both clients, written to `infra/secrets/google-services.json` in the main checkout (git-ignored). M8 copies it into `android/app/google-services.json` (git-ignored) with `infra/dev/copy-google-services.sh <main checkout>`, written by M2c-dev.
6. `gcloud iam service-accounts create kvitt-fcm --display-name "Kvitt FCM sender"`; grant `roles/firebasecloudmessaging.admin` on the project; `gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com`.
7. Copy the key to `/opt/kvitt/secrets/fcm-service-account.json` on the Pi (mode 0440, group 10001). This step belongs to M2c-pi.
7. Copy the key to `/opt/kvitt/secrets/fcm-service-account.json` on the Pi (mode 0440, root:10001). Done by `infra/pi/install.sh`, run by the owner (D-103).
8. If an organisation policy blocks service-account key creation, stop and report; do not change org policies.
The agent may write these git-ignored files under `infra/secrets/` in the main checkout and act on the Google Cloud project `kvitt-1337` for steps 2 to 6 (D-022); nothing else outside the repository.
State after M2c-dev (`c885aba`): steps 1 to 6 are done. Firebase is added to project `kvitt-1337`; Android apps exist for `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; the APIs `firebase.googleapis.com`, `fcm.googleapis.com`, `cloudresourcemanager.googleapis.com` and `iam.googleapis.com` are enabled; the service account is `kvitt-fcm@kvitt-1337.iam.gserviceaccount.com` with role `roles/firebasecloudmessaging.admin` (a role named `roles/firebasemessaging.admin` does not exist); `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` (both clients) exist in the main checkout. `infra/README.md` documents the REST calls for repeating or inspecting the setup. Step 7 remains for M2c-pi.
State after M2c-dev (`c885aba`): steps 1 to 6 are done. Firebase is added to project `kvitt-1337`; Android apps exist for `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; the APIs `firebase.googleapis.com`, `fcm.googleapis.com`, `cloudresourcemanager.googleapis.com` and `iam.googleapis.com` are enabled; the service account is `kvitt-fcm@kvitt-1337.iam.gserviceaccount.com` with role `roles/firebasecloudmessaging.admin` (a role named `roles/firebasemessaging.admin` does not exist); `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` (both clients) exist in the main checkout. `infra/README.md` documents the REST calls for repeating or inspecting the setup. Step 7 is part of the owner's run of the M2c-pi bundle (checklist step 6).
The FCM free tier is sufficient; no billing account is required.
@@ -1086,17 +1094,17 @@ Production run (M11, D-025), in this order:
### 10.9 Backups and restore
Backup (`infra/backup/backup.sh`, daily 03:30 Europe/Stockholm, also runnable as `docker compose exec backup /backup.sh`):
Backup (`infra/backup/backup.sh`, daily 03:30 Europe/Stockholm, also runnable as `docker compose exec backup /backup.sh`, or on the Pi as `/opt/kvitt/backup-now.sh`, which waits for `last-success` to change):
1. `sqlite3 /data/kvitt.sqlite ".backup /backups/staging/kvitt.sqlite"` (online, consistent).
2. `sqlite3 /backups/staging/kvitt.sqlite "PRAGMA integrity_check"` must print `ok`, else abort with a log line.
3. `gzip -9` to `/backups/kvitt-<UTC yyyyMMddTHHmmssZ>.sqlite.gz`; `tar -czf /backups/pictures-<same stamp>.tar.gz -C /data pictures`.
4. `rclone copy /backups/<both files> "${RCLONE_REMOTE}" --drive-root-folder-id "${RCLONE_FOLDER_ID}" --immutable` (copy only; never `sync`, never delete, never overwrite).
5. On success write `/backups/last-success` with the stamp. The local files are deleted from the Pi's `backups` volume as soon as both are confirmed on Drive (`rclone lsf`) (D-002); nothing on Drive is ever deleted. When `RCLONE_FOLDER_ID` is empty (the development stack, D-001), step 4 is skipped with a log line and the local files are kept.
4. For every pending `kvitt-*.sqlite.gz` and `pictures-*.tar.gz` in `/backups` (this run's pair and any leftovers of an earlier failed run): `rclone copy <file> "${RCLONE_REMOTE}" --drive-root-folder-id "${RCLONE_FOLDER_ID}" --immutable` (copy only; never `sync`, never delete, never overwrite), then confirm it by name and size with `rclone lsf` (D-106). rclone uses a temporary copy of the read-only `rclone.conf` (D-105).
5. Only after every file is confirmed are the local copies deleted from the Pi's `backups` volume (D-002), and only then is `/backups/last-success` written with the stamp (D-106). A failed upload or confirmation keeps the local files and leaves `last-success` unchanged; the next good run uploads the leftovers. Nothing on Drive is ever deleted. When `RCLONE_FOLDER_ID` is empty (the development stack, D-001), step 4 is skipped with a log line, the local files are kept and `last-success` is written.
rclone setup (HUMAN, once): on the owner's laptop run `rclone authorize "drive"` and sign in; the infra agent writes `infra/secrets/rclone.conf` with a `[gdrive]` remote (`type = drive`, `scope = drive`, the token from the authorize step) and copies it to the Pi. The Drive destination root folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (OQ-4, answered); it is the value of `RCLONE_FOLDER_ID` in the Pi's `.env`.
rclone setup (HUMAN, once, done 2026-10-18): the owner ran `rclone authorize "drive"` and signed in; the token (`infra/secrets/rclone-drive-token.json`) became `infra/secrets/rclone.conf` with a `[gdrive]` remote (`type = drive`, `scope = drive`, `token`), both git-ignored. The owner stages it with the bundle and `install.sh` installs it on the Pi (§10.2); the folder id stays in `.env` as `RCLONE_FOLDER_ID`, not in the conf. The Drive destination root folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (OQ-4, answered); it is the value of `RCLONE_FOLDER_ID` in the Pi's `.env`.
Restore (documented step by step in `infra/RESTORE.md`):
Restore (documented step by step in `infra/RESTORE.md`; its commands run through the `backup` container, which has `sqlite3`, `rclone`, the Drive credentials and uid 10001):
1. `docker compose stop server`.
2. Download the chosen `kvitt-*.sqlite.gz` and `pictures-*.tar.gz` from Drive (rclone or browser) to the Pi.
@@ -1109,10 +1117,10 @@ Restore (documented step by step in `infra/RESTORE.md`):
### 10.10 Operations
* Logs: `docker compose logs <service>`.
* Disk: SQLite is a few MB; pictures under 1 MB; APKs about 20 MB each, 5 kept.
* Upgrades: build, push, `infra/deploy.sh <tag>`. Migrations run on start; a failed migration exits non-zero, the container restarts in a loop, and the previous tag is redeployed with `infra/deploy.sh <previous tag>` (migrations are forward-only, so the infra plan's runbook says to restore the pre-upgrade backup if a migration had partially applied; sqlx runs each migration in a transaction, so this should not happen).
* Before every deploy that includes a migration, `infra/deploy.sh` triggers a backup run and waits for `last-success` to update.
* Who runs what (D-100): every command on the Pi in this section is run by the owner from the M2c-pi bundle's scripts and README checklist; `infra/deploy.sh` here stands for the bundle's deploy script. Agents prepare and check the bundle on the Mac and never connect to the Pi.
* Disk: SQLite is a few MB; pictures under 1 MB; APKs about 11 MB each since R8 (§10.6), 5 kept.
* Upgrades: the Gitea workflow builds and pushes a `v1.0.N` tag (or `infra/build-images.sh` on the Mac), then the owner runs `/opt/kvitt/deploy.sh <tag>` on the Pi. Migrations run on start; a failed migration exits non-zero, the container restarts in a loop, and the previous tag is redeployed with `/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup` (migrations are forward-only, so the infra plan's runbook says to restore the pre-upgrade backup if a migration had partially applied; sqlx runs each migration in a transaction, so this should not happen).
* Before every deploy, `deploy.sh` runs a backup and waits for `last-success` to update: the rule "back up before a deploy with a migration" is applied to every deploy, because the Pi cannot tell beforehand whether a tag carries one (D-103).
* Who runs what (D-100): every command on the Pi in this section is run by the owner from the M2c-pi bundle's scripts and README checklist; the deploy script is `infra/pi/deploy.sh`, installed as `/opt/kvitt/deploy.sh` (D-103); the owner's operations table is in `infra/README.md`. Agents prepare and check the bundle on the Mac and never connect to the Pi.
* Memory check: `docker stats --no-stream` after the release milestone; server RSS must be under 100 MB with both phones syncing.
---
@@ -1155,9 +1163,14 @@ M8 phone test (2026-10-18) ─► M8b UI fixes (landed f0575d3, D-097) ─► M2
After v0.15 (v0.16):
M8b ─► M8c goldens and About (landed b481fab, 64f5706, 7c453d8; D-098, D-099) ─► M8d editor rows (landed 3c68594, D-102) ─► M8e (D-107)
R8 release build (landed 57f984f)
After v0.16 (v0.17):
M2c-pi bundle (landed 5e027df, D-103 to D-106) ─► owner runs the bundle on the Kvitt Pi ─► M10
M8e S-19 option rows (landed 15397e4, D-107) ─► chips cleanup (landed 4755fe0)
M8f instant navigation (landed ed1b130, D-108)
```
After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001), as the last step once the app is proven on the phone against the development server (D-100). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: `6b60ef7`, `2e2ebe7`, `e499107`). M12d (D-059) ran in parallel with M6 (landed `bdba9d3` and `3080804`); M7a to M7f ran in parallel and landed on 2026-10-18 (`b9b061a`, `31760f9`, `1e42f76`, `082b8dc`, `6ac711d`, `a54eb7c`), followed by M6b, the `:core:design` fix pass (`3a1bd14`). M8 and M7g (feature cleanups) ran in parallel (disjoint paths: `android/app/**` and `android/adapter/fcm/**` against `android/feature/**`) and landed on 2026-10-18 (`34a2590`; `369e516` and `00a14a3`). The contract pass M12e (D-086 to D-095) ran after them and landed on 2026-10-18 (`1470a2f` contract pass, `90505a8` launcher icon).
After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001), as the last step once the app is proven on the phone against the development server (D-100). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: `6b60ef7`, `2e2ebe7`, `e499107`). M12d (D-059) ran in parallel with M6 (landed `bdba9d3` and `3080804`); M7a to M7f ran in parallel and landed on 2026-10-18 (`b9b061a`, `31760f9`, `1e42f76`, `082b8dc`, `6ac711d`, `a54eb7c`), followed by M6b, the `:core:design` fix pass (`3a1bd14`). M8 and M7g (feature cleanups) ran in parallel (disjoint paths: `android/app/**` and `android/adapter/fcm/**` against `android/feature/**`) and landed on 2026-10-18 (`34a2590`; `369e516` and `00a14a3`). The contract pass M12e (D-086 to D-095) ran after them and landed on 2026-10-18 (`1470a2f` contract pass, `90505a8` launcher icon). The M2c-pi bundle (`5e027df`, D-103 to D-106), M8e (`15397e4`, D-107) and M8f (`ed1b130`, D-108) landed on 2026-10-18; what remains of M2c-pi is the owner running the bundle, and M10 waits for it.
### 11.2 Milestone table
@@ -1170,7 +1183,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| M2a | Server skeleton and `kvitt-domain` | Cargo workspace, three crates, config, DB pool, migration `0001_init.sql` byte-identical to `contracts/schema/server-v1.sql`, error type with every §14.3.2 code, auth extractor (§8.2), all routes of §7 registered with handlers returning 501 `not_implemented`, utoipa document equal to `contracts/openapi.yaml`, `GET /health` and `GET /setup/status` implemented, `kvitt-domain` passing all vectors plus `next_occurrence_date`, `server/Dockerfile`; shell files in M3, M4 and M9 paths (D-004); the seams of §6.3 (D-015) | Server gate (includes the OpenAPI match test, the migration identity test, the vector tests) | none | M1 |
| M2b | Android skeleton and `:domain` | Gradle project in `android/` with every module of §6.2, version catalog, architecture check, `:core:model` types and repository interfaces of §14.2.4, `:domain` passing all vectors (including analytics), `:core:fakes` with the empty scenario (§6.2, D-018) for each repository, the Room 3 entities of §14.2.1 and §14.2.2 and `KvittDatabase` version 1 in `:data` with the exported schema (D-012), screenshot tests with Roborazzi (D-010), module shells for every module with one empty entry composable per feature (D-004), the feature convention of §6.2 in `build-logic` (D-017), `:app` launching to an empty themed screen, `:app` string files with the app name only in both languages, lint `MissingTranslation` as an error in every module | Android gate | Plug the phone in once to confirm `installDebug` works | M1 |
| 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, D-100) | A self-contained bundle in `infra/pi/` that the owner copies to the Kvitt Pi and runs (D-100): compose stack, Caddyfile, env template with the OQ-3 values as placeholders, cert-sync image, install, deploy, bootstrap and backup scripts, a README checklist for the owner (Pi provisioning, router forwarding, trust of the `gitea.lan` registry, the cert-sync key on the Home Assistant SSH add-on, `rclone authorize "drive"`), `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) | On the Mac: the bundle's compose file passes `docker compose config` with the env template; `shellcheck $(find infra -name '*.sh')` clean. On the Pi, run by the owner from the checklist: 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 | Copy the bundle to the Kvitt Pi and fill the placeholders in its `.env` (OQ-3); run the README checklist: install Docker if missing, add `gitea.lan` to `insecure-registries`, set the router forward 8443 → Kvitt Pi, add the cert-sync public key to the Home Assistant SSH add-on, `rclone authorize "drive"`, provide the example Gitea Actions workflow and the Gitea credentials; answer yes/no per check. No allowances for agents on the Pis, Home Assistant, Gitea or Drive (D-100) | M2c-dev, M8; runs last, after the app is proven (D-100) |
| M2c-pi | Infra on the Pi (D-001, D-100) | Bundle landed `5e027df` 2026-10-18 (D-103 to D-106); the owner's run on the Pi remains. A self-contained bundle in `infra/pi/` that the owner copies to the Kvitt Pi and runs (D-100): compose stack, Caddyfile, env template with the OQ-3 values as placeholders, cert-sync image, install, deploy, bootstrap and backup scripts, a README checklist for the owner (Pi provisioning, router forwarding, trust of the `gitea.lan` registry, the cert-sync key on the Home Assistant SSH add-on, `rclone authorize "drive"`), `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) | On the Mac: `docker compose -f infra/pi/compose.yaml config` succeeds with the env template (D-103); `shellcheck $(find infra -name '*.sh')` clean (green at landing: shellcheck clean on 18 scripts, compose config valid, the cert-sync arm64 image builds; rehearsed on the Mac with Podman: cert-sync install, renew, skip and refuse cases, Caddy reload through the admin API, `Authorization` redacted in Caddy's log, TLS 1.1 refused, backup upload with `rclone lsf` confirmation and deletion after confirmation, retry after a failed upload, backup healthcheck red at 26 h, APK prune keeps 5; not verifiable from the Mac: the Home Assistant add-on's `authorized_keys` options, the Gitea workflow run, pulls from `gitea.lan`, the router forward, Drive, real Docker compose). On the Pi, run by the owner from the checklist: 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 | Copy the bundle to the Kvitt Pi and fill the placeholders in its `.env` (OQ-3); run the README checklist: install Docker if missing, add `gitea.lan` to `insecure-registries`, set the router forward 8443 → Kvitt Pi, add the cert-sync public key to the Home Assistant SSH add-on, `rclone authorize "drive"`, provide the example Gitea Actions workflow and the Gitea credentials; answer yes/no per check. Done by 2026-10-18: the Drive token (`rclone.conf`), the example workflow, the Gitea secret `kvitt_cicd`, the cert-sync key `infra/secrets/ha_ssh_key`. Remaining: the owner runs the bundle, the nine steps of `infra/README.md` "Pi: what the owner does" (provision, Docker, the add-on key variant A or B, `.env` and secrets, copy and host key, `install.sh`, router forward, first backup and bootstrap, deploy). No allowances for agents on the Pis, Home Assistant, Gitea or Drive (D-100) | M2c-dev, M8; runs last, after the app is proven (D-100) |
| 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 | 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 |
@@ -1190,7 +1203,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| M8 | App integration | Landed `369e516` and `00a14a3` 2026-10-18 (D-081 to D-085). `:app` navigation and deep links (§13.2, D-071; each top-level screen draws its own bar), session gate (S-1 for Unpaired and Unrecognised, S-2, S-18 for Revoked, back on S-18 closes the app; Home shows S-3 while `partner == null`), deletion of the debug fakes module (both variants bind `:data`, D-081), `:adapter:fcm` with registration by Firebase Installation ID (D-083), push work and notification building (AC-13.5, D-085), invite links ignored while paired (D-084), `ProvideHousehold` as `LocalKvittHousehold`, `LocalKvittClock` from `:data`'s Clock, `LocalAvatarPictures` handing the `User` to `:data`'s Coil mapper, notification permission after the first successful sync after pairing (AC-2.9), update check in release only, revoke wipe, network security configs (§8.5, D-082), manifest hardening (§8.8), release signing and version properties (§10.6; unsigned without keystore keys), `google-services.json` copied with `infra/dev/copy-google-services.sh` | Android gate (green at landing: BUILD SUCCESSFUL, 710 actionable tasks); on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced, receive a push (run on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device`; Home showed the account; the offline expense showed the pending marker and synced after reconnect, pending 0, row on the server; the device registered `fcm_token` and `app_version_name`; a push by `fid` arrived and its tap worked). Follow-up M8b (landed `f0575d3`, D-097) fixed what the test showed: the invite-code field, the quick-row badge, the status-bar inset of `KvittTopAppBar`, the chart corners; `FcmMessageBody.fid` in `:data` followed (`09f474f`, D-096) | Tap through the pairing, offline and push test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) |
| M12e | Contract pass | Landed `1470a2f` and `90505a8` 2026-10-18 (D-086 to D-095). The §11.3 contract requests of M5 to M8 taken together on both sides: E-3 `invite_kind` and `Pairing` (D-086); the admin's name in E-12 links, passed on by E-4 (D-087); `SyncSummary.lastAttemptFailed` (D-088); `Rejection.actor` (D-089); `X-Kvitt-App-Version-Name`, `devices.app_version_name` by migration 0003, server schema 3 (D-090); `NewRecurringTemplate` (D-091); `UpdateCheck` (D-092); `SettleState.untouched` (D-093); the D-027 re-stamp in `:data` (D-094); `PushEvent` in `:core:model`, snackbar hosts in six screens, snackbar resource ids from S-7 and S-8, the adaptive launcher icon (D-095). Goldens re-recorded: S-20 devices (version name); new `s1_pairing_link_admin` and `s22_about_check_failed` | Both gates (green at landing: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; server 253 tests, the OpenAPI equality, vector and migration byte-identity tests included) | none (the development server must be rebuilt before the new APK is installed, §14.8) | M8, M7g, SPEC v0.12 |
| M9 | Splitwise importer | Landed `ab298ab` (D-044 to D-046). `kvitt-import` per §10.8 with tests over synthetic CSV fixtures (`crates/kvitt-import/tests/import.rs`), reconciliation against the totals rows, the committed category table | Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 |
| M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore with the `keytool` command of §10.6 at the repository root, or allow an agent to (D-101), and keep a copy and the password in the password manager; run the Pi steps from the M2c-pi bundle (D-100); install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi, M3, M4, M8 |
| M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore with the `keytool` command of §10.6 at the repository root, or allow an agent to (D-101), and keep a copy and the password in the password manager; run the Pi steps from the M2c-pi bundle (D-100); install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi (the owner's run of the bundle), M3, M4, M8 |
| M11 | Import history | Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044). Dry run of 2026-10-18 with the M12b mapping (D-053): Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. Real import run on the Pi per §10.8 "Production run" (D-025), `docs/validation/splitwise-import.md` with per-account totals compared to Splitwise's balances (aggregates only) | Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 |
### 11.3 Notes per milestone
@@ -1208,9 +1221,9 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
The `fid` request was taken on 2026-10-18 (`c694f78`, D-096): the server sends the installation id in FCM HTTP v1 `message.fid` (§14.6).
* **M2c-dev / M2c-pi** (D-001, D-100): M2c-dev builds only what development needs on the Mac (landed); 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) as the `infra/pi/` bundle the owner runs, and runs last, after M8 and the phone test, before M10.
* **M4** (landed `bd6f10a`) added `into_make_service_with_connect_info::<SocketAddr>()` 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** (landed `369e516` and `00a14a3`, D-081 to D-085) is the only plan that edits `:app` after M2b. As built: both variants bind `:data` through `di/AppDataModule.kt`, which includes `DataBindingsModule`; the debug fakes module is deleted (D-081, revising D-041). The session gate shows S-1 for Unpaired and Unrecognised, S-2, and S-18 for Revoked (back on S-18 closes the app); Home shows S-3 while `partner == null`; each top-level screen draws its own bar (D-071). M6's `:app` requests are met: `ProvideHousehold` is implemented as `LocalKvittHousehold`, `LocalKvittClock` is provided from `:data`'s Clock, and `LocalAvatarPictures` passes the `User` model to `:data`'s Coil mapper. M5's requests are met: `AppBuildInfo` from `BuildConfig`; WorkManager with `KvittWorkerFactory` and the default initializer disabled; `SyncTriggers.onAppStart` on process start and `onForeground` on later returns; push handled by an expedited WorkManager job calling `syncForPush` (D-085); `UpdateRepository.check` on every start and resume, in release builds only (AC-16.5); Coil's singleton is `:data`'s `ImageLoader`. The notification permission is asked after the first successful sync after pairing (AC-2.9). Network security configs per §8.5 (D-082); the merged release manifest exports only `MainActivity` and permission-guarded library receivers (§8.8); `:app:assembleRelease` builds unsigned when `local.properties` has no keystore keys (§10.6); FCM registration by Firebase Installation ID (D-083); invite links ignored while paired (D-084). The end-to-end test ran on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device` (the CLI has no partner-invite subcommand; small ops request for a later server plan); Home showed the account; an expense added offline showed the pending marker and synced after reconnect (pending 0, the server has the row); the owner's device registered `fcm_token` and `app_version_name`; a push by `fid` was delivered and the notification tap worked. **M8b** (landed `f0575d3`) fixed what the owner saw: the invite-code field holds the raw code and draws the hyphens as a `VisualTransformation` with an exact offset mapping (cursor bug fixed, unit test `InviteCodeFieldTest`); the quick-row and grid selected badge is drawn above the ripple clip (it was cut off); `KvittTopAppBar` pads for the status-bar inset through a `windowInsets` parameter and `KvittScaffold` follows; chart bar corners top 4, bottom 2 dp (D-097); 221 goldens re-recorded. `FcmMessageBody.fid` in `:data` followed in `09f474f` (D-096). Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked and had no complaint). Its contract requests were taken by M12e (D-086 to D-095); the `fid` field followed in `c694f78` (D-096).
* **M8** (landed `369e516` and `00a14a3`, D-081 to D-085) is the only plan that edits `:app` after M2b. As built: both variants bind `:data` through `di/AppDataModule.kt`, which includes `DataBindingsModule`; the debug fakes module is deleted (D-081, revising D-041). The session gate shows S-1 for Unpaired and Unrecognised, S-2, and S-18 for Revoked (back on S-18 closes the app); Home shows S-3 while `partner == null`; each top-level screen draws its own bar (D-071). M6's `:app` requests are met: `ProvideHousehold` is implemented as `LocalKvittHousehold`, `LocalKvittClock` is provided from `:data`'s Clock, and `LocalAvatarPictures` passes the `User` model to `:data`'s Coil mapper. M5's requests are met: `AppBuildInfo` from `BuildConfig`; WorkManager with `KvittWorkerFactory` and the default initializer disabled; `SyncTriggers.onAppStart` on process start and `onForeground` on later returns; push handled by an expedited WorkManager job calling `syncForPush` (D-085); `UpdateRepository.check` on every start and resume, in release builds only (AC-16.5); Coil's singleton is `:data`'s `ImageLoader`. The notification permission is asked after the first successful sync after pairing (AC-2.9). Network security configs per §8.5 (D-082); the merged release manifest exports only `MainActivity` and permission-guarded library receivers (§8.8); `:app:assembleRelease` builds unsigned when `local.properties` has no keystore keys (§10.6); FCM registration by Firebase Installation ID (D-083); invite links ignored while paired (D-084). The end-to-end test ran on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with `kvitt-server pair-device` (the CLI has no partner-invite subcommand; small ops request for a later server plan); Home showed the account; an expense added offline showed the pending marker and synced after reconnect (pending 0, the server has the row); the owner's device registered `fcm_token` and `app_version_name`; a push by `fid` was delivered and the notification tap worked. **M8b** (landed `f0575d3`) fixed what the owner saw: the invite-code field holds the raw code and draws the hyphens as a `VisualTransformation` with an exact offset mapping (cursor bug fixed, unit test `InviteCodeFieldTest`); the quick-row and grid selected badge is drawn above the ripple clip (it was cut off); `KvittTopAppBar` pads for the status-bar inset through a `windowInsets` parameter and `KvittScaffold` follows; chart bar corners top 4, bottom 2 dp (D-097); 221 goldens re-recorded. `FcmMessageBody.fid` in `:data` followed in `09f474f` (D-096). Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked and had no complaint). Its contract requests were taken by M12e (D-086 to D-095); the `fid` field followed in `c694f78` (D-096). **M8e** (landed `15397e4`, D-107) turned S-19's account, payer and split chips into `EditorOptionRows`; the unused `AccountChip`, `PayerChip` and `SplitChip` were removed in `4755fe0`. **M8f** (landed `ed1b130`, D-108) made navigation instant (`NavTransitions` in `:app`).
* **M10**: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 at the repository root and referenced from `android/local.properties` (D-024, D-101).
* **M2c-pi** (D-100, revises D-023): no SSH from the Mac to the Pis and no allowances for agents on the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. The agent writes and checks the `infra/pi/` bundle on the Mac; the owner copies it to the Kvitt Pi, fills the `.env` placeholders (OQ-3) and runs the README checklist, answering yes/no per check. It runs as the last step before M10, after the app is proven on the phone.
* **M2c-pi** (D-100, revises D-023): no SSH from the Mac to the Pis and no allowances for agents on the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. The agent writes and checks the `infra/pi/` bundle on the Mac; the owner copies it to the Kvitt Pi, fills the `.env` placeholders (OQ-3) and runs the README checklist, answering yes/no per check. It runs as the last step before M10, after the app is proven on the phone. The bundle landed `5e027df` on 2026-10-18 (D-103 to D-106: layout and scripts, image names and the Gitea workflow, the backup uid, the cert-sync and backup refinements); the remaining part is HUMAN, the owner running the bundle, and M10 depends on it.
* **M9** (landed `ab298ab`) reuses the server's own push validation (`kvitt_server::api::validate`, `rows`) and the `SqlAuditWriter`, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary, `crates/kvitt-import/tests/import.rs`, over synthetic fixtures only; one test checks the §10.8 category table against `data/splitwise_categories.csv`.
* **M12a, M12b, M12c** (D-049 to D-054) carry the owner's UX-session decisions into the contracts and the landed code: M3, M5 and M9 were built with ten defaults, so this is a contract change against `main`, not only the spec. M12a (landed `6b60ef7`) wrote `contracts/`, the checker and `:domain`'s `suggestCategory`; M12b (landed `2e2ebe7`) and M12c (landed `e499107`) ran in parallel (disjoint paths, §14.7); their outcomes are D-055 to D-058. The Room schema stays at version 1: category rows reach the phone through sync. The colour hex values of `slate` and `amber` are not needed by M12 (`:core:model` holds token names only); they arrive with U2 and M6.
* **M12d** (D-059, landed `bdba9d3`) added the two UI-contract members UX-9 found (`ExpenseEditorOptions.frequentCategoryIds`, `CategoryRepository.deletable`) to `:core:model`, `:data` and `:core:fakes`, in parallel with M6 (disjoint paths); its readings are D-060 to D-062 (§14.2.4 "Readings fixed in v0.10"). M7c and M7f consume them; the design spec's fallbacks (`docs/ux/design-spec.md` §3.7 and §6) are no longer needed.
@@ -1225,7 +1238,7 @@ Only things that cannot be decided without the owner. Everything else in this sp
| Id | Question | Needed by | Default if unanswered |
|---|---|---|---|
| OQ-1 | Answered (D-001): the registry is a Gitea instance on another Pi at `http://gitea.lan`, plain HTTP, reachable only on the home Wi-Fi; images are built by Gitea Actions later from the owner's example workflow. | M2c-pi | answered |
| OQ-1 | Answered (D-001): the registry is a Gitea instance on another Pi at `http://gitea.lan`, plain HTTP, reachable only on the home Wi-Fi; images are built by Gitea Actions from the owner's example workflow (provided; `.gitea/workflows/build-images.yaml`, D-104). | M2c-pi | answered |
| OQ-2 | Answered (D-003): two exports, English and Swedish header variants, member columns in either order, ISO dates, point decimal, double-quoted fields, trailing empty line and `Total balance` / `Totalsumma` row, payment category `Payment` / `Betalning`, all rows SEK. Group "hemma" becomes account "Hemma", the general group becomes account "Vardag". Details §10.8 and PLAN-M9. | M9, M11 | answered |
| OQ-3 | Answered by placeholders (D-100): the LAN address of the Kvitt Pi; LAN address, SSH port and user of the Home Assistant Pi; the directory holding `fullchain.pem` and `privkey.pem` there (expected `/ssl` on Home Assistant OS); which SSH add-on is used. The owner fills them in the M2c-pi bundle's `.env` on the Pi; no agent needs them. | M2c-pi | answered |
| OQ-4 | Answered: the Google Drive backup folder id is `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV` (§10.9). | M2c-pi | answered |
@@ -1251,6 +1264,7 @@ A brief for one UX agent (Opus) who has read this spec and nothing else. It runs
### 13.2 Navigation model (binding, D-048)
* Destination changes are instant, with no transition animation (D-108); motion stays inside screens (design-spec §2.11).
* Bottom navigation with three destinations: **Home** (Översikt), **Analytics** (Statistik), **Settings** (Inställningar). An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth destination and not taken for v1 (D-048).
* A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail.
* Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back.
@@ -1283,7 +1297,7 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
| S-16 | Sync status | Last successful sync time; pending changes count with a list (the shared ledger rows of `:core:design`, D-019, not a separate 36 dp row); "Changes not synced" list from `sync_rejections` with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync failed" when online and `SyncSummary.lastAttemptFailed` (D-088); the stale-change copy names `Rejection.actor`, the partner when it is null (D-089); "Sync now", online only (§9.9, D-080) | Sync now; open a rejected change (read-only); dismiss it |
| S-17 | Update available | Banner on Home with version name; dialog explaining "Install unknown apps" the first time; download progress; failure | Update; Later |
| S-18 | Device removed | Explanation that this phone's access was removed and local data deleted | Pair again (goes to S-1) |
| S-19 | Recurring expenses | List: description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 (no date) plus day of month (a new template defaults to today's day, AC-9.8, D-072), first month, optional last month, pause switch; a new template is sent as `NewRecurringTemplate` (`RecurringForm.newTemplate()`), an edit as `RecurringTemplate` (`RecurringForm.template(original, …)`), D-091; the discard dialog has no body text (D-080) | Add; Edit; Pause/Resume; Delete |
| S-19 | Recurring expenses | List: description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 (no date); account, payer and split as three fixed full-width rows (Konto, Betalt av, Delning; D-102, D-107) plus day of month (a new template defaults to today's day, AC-9.8, D-072), first month, optional last month, pause switch; a new template is sent as `NewRecurringTemplate` (`RecurringForm.newTemplate()`), an edit as `RecurringTemplate` (`RecurringForm.template(original, …)`), D-091; the discard dialog has no body text (D-080) | Add; Edit; Pause/Resume; Delete |
| S-20 | Devices | Rows per AC-3.1; the app version shows `Device.appVersionName` ("Kvitt 1.1"), else the version code (D-090) | Revoke (with confirmation) |
| S-21 | Invites | For the admin: "Invite partner" (only before the partner joins) and "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry, and a share action (Android share sheet); list of pending invites with cancel | Create; Share; Cancel |
| S-22 | About | App version name and code, server URL, server version (from `/health`); no open-source licences row (OQ-5, D-099); after "Check for update": `UpdateCheck.UpToDate` shows "Du har den senaste versionen" (`settings_about_up_to_date`), `UpdateCheck.Failed` shows "Kunde inte nå servern" (`common_could_not_reach_server`) (AC-16.6, D-092) | Check for update |
@@ -2661,9 +2675,10 @@ android/
feature/{onboarding, home, account, expense, settle, analytics, settings}/
app/
infra/
compose.yaml Caddyfile env.example build-images.sh deploy.sh publish-apk.sh README.md RESTORE.md
.shellcheckrc dev/{compose.yaml, .env.example, lib.sh, build.sh, up.sh, logs.sh, backup.sh, copy-google-services.sh} dev/data/, dev/backups/ (git-ignored)
cert-sync/{Dockerfile, sync.sh} backup/{Dockerfile, backup.sh} secrets/ (git-ignored)
build-images.sh publish-apk.sh README.md RESTORE.md .shellcheckrc
pi/{compose.yaml, Caddyfile, .env.example, .gitignore, install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh, lib.sh} pi/.env, pi/secrets/, pi/out/ (git-ignored)
dev/{compose.yaml, .env.example, lib.sh, build.sh, up.sh, logs.sh, backup.sh, copy-google-services.sh} dev/data/, dev/backups/ (git-ignored)
cert-sync/{Dockerfile, sync.sh, entrypoint.sh} backup/{Dockerfile, backup.sh, healthcheck.sh} secrets/ (git-ignored)
```
Ownership (an agent edits only paths its milestone owns; anything else needs the orchestrator):
@@ -2750,3 +2765,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 0.14 | 2026-10-18 | Follow-ups after M12e (`2b4e21c`, `c694f78`, `1e404cc`). D-096: the server sends the stored `fcm_token` (the Firebase Installation ID of D-083) in FCM HTTP v1 `message.fid`, no longer in the deprecated `message.token`; no database or wire change, the column and the E-17 field keep the name `fcm_token`; `contracts/examples/push/*.json` use `fid`; revises D-083's "no server change" (§14.6, AC-13.7, §11.3). Without a decision id: the Pi image's C stage and CA roots and an OAuth plus FCM call from the arm64 image confirmed on the Mac (`docs/spikes/pi-image-arm64.md`; 63.8 MB, cold build 48 s), running on the Pi stays M2c-pi's (§10.3); Vico removed from the version catalog (`2b4e21c`), D-063 pointer (§6.2); §11.3 remaining contract requests: only OQ-5. |
| 0.15 | 2026-10-18 | The first phone test, M8b (`f0575d3`) and owner answers. D-097: chart bars top corners 4 dp, bottom 2 dp, replacing 10/4 (§6.2, §13.4, design-spec §2.9, §3.10). D-100: no SSH from the Mac to the Pis; M2c-pi delivers the `infra/pi/` bundle the owner copies to the Kvitt Pi and runs, OQ-3 values as `.env` placeholders, M2c-pi last after the app is proven, no agent allowances on the Pis, Home Assistant, Gitea or Drive; revises D-023, keeps D-001 (§10 intro, §10.1, §10.10, §11.1, §11.2, §11.3, §12). D-101: the release keystore `kvitt-release.jks` at the repository root, git-ignored by `*.jks`, absolute path in `android/local.properties`, copy and password in the owner's password manager; revises D-024 (§10.6, §11.2 M10, §11.3). Without a decision id: the M8 end-to-end test passed on the owner's S26 against the development server (pairing, scripted partner via a second admin device, offline pending and sync, `fcm_token` and `app_version_name` registered, push by `fid` delivered and tapped); M8b fixes (invite-code `VisualTransformation`, quick-row badge above the ripple clip, `KvittTopAppBar` status-bar inset, 221 goldens) and `FcmMessageBody.fid` (`09f474f`) (§11.2 M8, §11.3); OQ-5 answered: licences button hidden in v1 (M8c); the 128 px picture size not needed (§14.2.4); the "boende" keyword keeps suggesting `travel` and the account page split bar stays as designed (no change). |
| 0.16 | 2026-10-18 | M8c (`b481fab`, `64f5706`, `7c453d8`), the R8 release build (`57f984f`) and M8d (`3c68594`). D-098: goldens in three variants `<name>_sv_light`, `<name>_sv_dark`, `<name>_en_light` plus `<name>_sv_light_200`, rendered at 1x through `w411dp-h<height>dp-mdpi`, every committed PNG compressed with `oxipng -o 4 --strip safe` (1143 PNGs / 131 MB to 865 / 26 MB, mock-ups 36.8 to 26.0 MB); revises D-066 (§6.2, §13.4). D-099: S-22 has no open-source licences row, `onOpenLicences` and `settings_about_licences` removed, `strings.csv` 577 rows; closes OQ-5 (§11.3, §12, §13.3, §13.4, design-spec S-22). D-102: S-7 options as four fixed full-width rows (Konto, Datum, Betalt av, Delning), `EditorOptionRows` in `:core:design`; connected buttons 8 dp side padding (§13.3, §13.4, design-spec §3.7, §3.10). Without a decision id: release builds R8-shrunk, optimised and obfuscated, APK about 11 MB (was 43 MB), `mapping.txt` kept beside the APK; release signing verified (alias `kvitt`, RSA 4096, v2) (§10.6); §11.1 graph. |
| 0.17 | 2026-10-18 | The M2c-pi bundle (`5e027df`), M8e (`15397e4`), M8f (`ed1b130`), the chips cleanup (`4755fe0`) and the Dockerfile `CARGO_BUILD_JOBS` argument (`965bb5d`). D-103: the `infra/pi/` bundle (`compose.yaml`, was `infra/compose.yaml`; `.env.example`, was `infra/env.example`; `Caddyfile`; `install.sh`, `deploy.sh <tag> [--skip-backup]` with a backup before every deploy and `/opt/kvitt/previous-tag`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`), the APK path `infra/publish-apk.sh` → `infra/pi/out/apk/` → `/opt/kvitt/apk/` → `install-apk.sh` into the `data` volume, mappings outside the volume, `.env` keys `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN`, `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`, gate `docker compose -f infra/pi/compose.yaml config`; fulfils D-100 (§10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2, §14.7). D-104: images `${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2`, the Gitea workflow `.gitea/workflows/build-images.yaml` (runner `host`, secret `kvitt_cicd`, `v1.0.N` plus `latest`, git tag pushed, `CARGO_BUILD_JOBS=1`), `infra/build-images.sh` as the Mac fallback, supercronic instead of busybox crond, `openssl` in cert-sync, pinned versions and Docker Engine from Docker's Debian arm64 repository (§10.1, §10.2, §10.3, §12 OQ-1). D-105: `backup` runs as uid 10001 on the Pi, `rclone.conf` 0600 owned 10001:10001, a temporary copy for refreshed tokens, volume owners set once by `install.sh`; revises D-023 for `rclone.conf` (§10.2). D-106: cert-sync always requests the `tar` stream (both `authorized_keys` variants), refuses a mismatched key or an earlier-expiring certificate, an unreachable admin API at first start is no failure; backup uploads every pending file, confirms each by name and size, deletes and writes `last-success` only after all are confirmed (§10.5.1, §10.9). D-107: S-19's account, payer and split as three fixed `EditorOptionRows` (Konto, Betalt av, Delning), extending D-102 (§13.3, design-spec §3.7, S-19). D-108: navigation is instant, no enter, exit, pop or size transition on the app's `NavHost` (§13.2, design-spec §2.11). Without a decision id: the Drive token, the cert-sync key and the Gitea secret `kvitt_cicd` exist; `server/Dockerfile` honours `CARGO_BUILD_JOBS` (§10.3); the unused `AccountChip`, `PayerChip`, `SplitChip` removed (design-spec §3.7); Caddy's `Authorization` redaction verified (§10.5.2); APKs about 11 MB (§10.10); §11.1 graph. |