diff --git a/SPEC.md b/SPEC.md index 298b937..8378fc0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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/`). +* 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..` 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:-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. 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 ` 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 [--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 ` (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 -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 -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-` 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-` 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-.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-.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-.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-.apk`, `kvitt-.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-.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
`, 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-.sqlite.gz`; `tar -czf /backups/pictures-.tar.gz -C /data pictures`. -4. `rclone copy /backups/ "${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 "${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 `. -* Disk: SQLite is a few MB; pictures under 1 MB; APKs about 20 MB each, 5 kept. -* Upgrades: build, push, `infra/deploy.sh `. 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 ` (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 ` 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::()` 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 `_sv_light`, `_sv_dark`, `_en_light` plus `_sv_light_200`, rendered at 1x through `w411dp-hdp-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 [--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. | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 34b6966..c9cdd94 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,8 +3,10 @@ Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this. ## 1. Where things stand -- `main` at `3c68594` (M8d) plus the SPEC v0.16 docs commit from `agent-docs-v016` 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, `23a2f1e` SPEC v0.6, `ab298ab` M9, `e8ee518` SPEC v0.7, `6b60ef7` M12a, `7fb9f6f` SPEC v0.8, `e499107` M12c, `2e2ebe7` M12b, `d452fa6` UX Kalas categories and tokens, `16c4d01` UX sprint U2, `45eecd1` SPEC v0.9, `bdba9d3` M12d, `3080804` M6, `7bd0aa1` SPEC v0.10, `72f21b2` app debug fakes bindings (temporary, deleted by M8), `e6ad858` app release wiring of `:data` (pre-M8), `b9b061a` M7a, `6ac711d` M7e, `082b8dc` M7d, `31760f9` M7b, `1e42f76` M7c, `a54eb7c` M7f, `3a1bd14` M6b, `d504004` SPEC v0.11, `34a2590` M7g, `369e516` and `00a14a3` M8, `cf8ef45` SPEC v0.12, `1470a2f` M12e contract pass, `90505a8` M12e launcher icon, `03b7184` SPEC v0.13, `2b4e21c` Vico catalog entries removed (D-063), `c694f78` server FCM target in `fid` (D-096), `1e404cc` Pi image arm64 check (`docs/spikes/pi-image-arm64.md`), `fd60170` SPEC v0.14, `f0575d3` M8b UI fixes from the first phone test, `09f474f` `FcmMessageBody.fid` in `:data` (D-096 follow-up), `e7d963a` SPEC v0.15, `b481fab` M8c goldens at 1x in three variants (D-098), `64f5706` M8c S-22 licences row removed (D-099), `7c453d8` every PNG through oxipng, `57f984f` R8 release build, `3c68594` M8d editor rows (D-102). -- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2a (`fdf2093`), M2b (`fad6fc9`), M2c-dev (`c885aba`), M3 (`503cee5`), M4 (`bd6f10a`), M5 (`c28c52b`), M9 (`ab298ab`), M12a (`6b60ef7`), M12b (`2e2ebe7`), M12c (`e499107`), U1 and U2 (`16c4d01`, owner approved), M12d (`bdba9d3`), M6 (`3080804`), M7a to M7f, M6b (`3a1bd14`), SPEC v0.11 (`d504004`, D-069 to D-080), M7g feature cleanups (`34a2590`), M8 app integration (`369e516` + `00a14a3`, landed 2026-10-18, gate green: BUILD SUCCESSFUL, 710 actionable tasks), SPEC v0.12 (`cf8ef45`, D-081 to D-085), M12e contract pass (`1470a2f` + `90505a8`, landed 2026-10-18; gates green: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; cargo 253 tests, OpenAPI equality, vectors and migration byte-identity green), SPEC v0.13 (`03b7184`, D-086 to D-095), Vico removal (`2b4e21c`), FCM `fid` target (`c694f78`, D-096), Pi image arm64 check (`1e404cc`: C stage, CA roots and an OAuth plus FCM call from the arm64 image confirmed on the Mac; running on the Pi stays M2c-pi's), SPEC v0.14 (`fd60170`, D-096), M8 on-phone test (2026-10-18, passed), M8b (`f0575d3`, D-097), `FcmMessageBody.fid` (`09f474f`), SPEC v0.15 (`e7d963a`, D-097, D-100, D-101); landed today after v0.15: M8c (`b481fab`, `64f5706`, `7c453d8`; D-098, D-099), the R8 release build (`57f984f`), M8d (`3c68594`, D-102). +- `main` at `4755fe0` (chips cleanup) plus the SPEC v0.17 docs commit from `agent-docs-v017` 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, `23a2f1e` SPEC v0.6, `ab298ab` M9, `e8ee518` SPEC v0.7, `6b60ef7` M12a, `7fb9f6f` SPEC v0.8, `e499107` M12c, `2e2ebe7` M12b, `d452fa6` UX Kalas categories and tokens, `16c4d01` UX sprint U2, `45eecd1` SPEC v0.9, `bdba9d3` M12d, `3080804` M6, `7bd0aa1` SPEC v0.10, `72f21b2` app debug fakes bindings (temporary, deleted by M8), `e6ad858` app release wiring of `:data` (pre-M8), `b9b061a` M7a, `6ac711d` M7e, `082b8dc` M7d, `31760f9` M7b, `1e42f76` M7c, `a54eb7c` M7f, `3a1bd14` M6b, `d504004` SPEC v0.11, `34a2590` M7g, `369e516` and `00a14a3` M8, `cf8ef45` SPEC v0.12, `1470a2f` M12e contract pass, `90505a8` M12e launcher icon, `03b7184` SPEC v0.13, `2b4e21c` Vico catalog entries removed (D-063), `c694f78` server FCM target in `fid` (D-096), `1e404cc` Pi image arm64 check (`docs/spikes/pi-image-arm64.md`), `fd60170` SPEC v0.14, `f0575d3` M8b UI fixes from the first phone test, `09f474f` `FcmMessageBody.fid` in `:data` (D-096 follow-up), `e7d963a` SPEC v0.15, `b481fab` M8c goldens at 1x in three variants (D-098), `64f5706` M8c S-22 licences row removed (D-099), `7c453d8` every PNG through oxipng, `57f984f` R8 release build, `3c68594` M8d editor rows (D-102), `3a3720b` SPEC v0.16, `5e027df` M2c-pi Pi bundle (D-103 to D-106), `15397e4` M8e S-19 option rows (D-107), `965bb5d` server Dockerfile `CARGO_BUILD_JOBS` argument, `ed1b130` M8f instant navigation (D-108), `4755fe0` unused editor chips removed. +- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2a (`fdf2093`), M2b (`fad6fc9`), M2c-dev (`c885aba`), M3 (`503cee5`), M4 (`bd6f10a`), M5 (`c28c52b`), M9 (`ab298ab`), M12a (`6b60ef7`), M12b (`2e2ebe7`), M12c (`e499107`), U1 and U2 (`16c4d01`, owner approved), M12d (`bdba9d3`), M6 (`3080804`), M7a to M7f, M6b (`3a1bd14`), SPEC v0.11 (`d504004`, D-069 to D-080), M7g feature cleanups (`34a2590`), M8 app integration (`369e516` + `00a14a3`, landed 2026-10-18, gate green: BUILD SUCCESSFUL, 710 actionable tasks), SPEC v0.12 (`cf8ef45`, D-081 to D-085), M12e contract pass (`1470a2f` + `90505a8`, landed 2026-10-18; gates green: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; cargo 253 tests, OpenAPI equality, vectors and migration byte-identity green), SPEC v0.13 (`03b7184`, D-086 to D-095), Vico removal (`2b4e21c`), FCM `fid` target (`c694f78`, D-096), Pi image arm64 check (`1e404cc`: C stage, CA roots and an OAuth plus FCM call from the arm64 image confirmed on the Mac; running on the Pi stays M2c-pi's), SPEC v0.14 (`fd60170`, D-096), M8 on-phone test (2026-10-18, passed), M8b (`f0575d3`, D-097), `FcmMessageBody.fid` (`09f474f`), SPEC v0.15 (`e7d963a`, D-097, D-100, D-101); landed today after v0.15: M8c (`b481fab`, `64f5706`, `7c453d8`; D-098, D-099), the R8 release build (`57f984f`), M8d (`3c68594`, D-102); SPEC v0.16 (`3a3720b`); then the M2c-pi bundle (`5e027df`, D-103 to D-106), M8e (`15397e4`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`965bb5d`), M8f (`ed1b130`, D-108) and the chips cleanup (`4755fe0`). +- M2c-pi bundle outcome (SPEC v0.17): `infra/pi/` with `compose.yaml`, `.env.example`, `Caddyfile`, `install.sh`, `deploy.sh [--skip-backup]` (backup before every deploy, `/opt/kvitt/previous-tag`), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh` (D-103); images `gitea.lan/jacobh2/kvitt-server|kvitt-cert-sync|kvitt-backup`, Gitea workflow `.gitea/workflows/build-images.yaml` (runner `host`, secret `kvitt_cicd`, tags `v1.0.N` + `latest`, `CARGO_BUILD_JOBS=1`), `infra/build-images.sh` as Mac fallback, supercronic instead of crond (D-104); `backup` uid 10001, `rclone.conf` owned 10001 (D-105); cert-sync tar stream for both key variants, backup confirm-then-delete (D-106). Gate at landing: shellcheck clean on 18 scripts, compose config valid, cert-sync arm64 image builds; rehearsed on the Mac with Podman (cert-sync install, renew, skip, refuse; Caddy reload; `Authorization` redacted; TLS 1.1 refused; backup upload, `lsf` confirmation, delete after confirm, failed-upload retry; healthcheck red at 26 h; APK prune keeps 5). Not verifiable from the Mac: the add-on's `authorized_keys` options, the Gitea workflow run, pulls from `gitea.lan`, the router forward, Drive, real Docker compose. +- M8e, M8f and cleanup outcome (SPEC v0.17): S-19's account, payer and split are three fixed `EditorOptionRows` (D-107); navigation is instant, no `NavHost` transitions (D-108); `AccountChip`, `PayerChip`, `SplitChip` removed (`4755fe0`), S-9 keeps `DateChip` in `EditorChipRow`. - M8 outcome (recorded in SPEC v0.12 as D-081 to D-085): both variants bind `:data` through `di/AppDataModule.kt`, debug fakes module gone (D-081, revises D-041); debug network security config allows cleartext everywhere, the AC-2.8 limit is enforced in `SessionRepositoryImpl.normaliseServerUrl`, pinned-root config behind `kvitt.tls.pinnedRoot` (D-082); FCM registration by Firebase Installation ID, firebase-messaging 26.0.0, no server change (D-083); invite links ignored while paired (D-084); generic-notification, deletion and `sync_batch` taps open Home, push in an expedited WorkManager job (D-085). - M8 end-to-end test (2026-10-18, dev server, owner's S26): 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; the offline expense 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. Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked, no complaint). - M8b outcome (SPEC v0.15): invite-code field holds the raw code, hyphens as a `VisualTransformation` (cursor bug fixed, unit test); quick-row selected badge drawn above the ripple clip; `KvittTopAppBar` pads for the status-bar inset (`windowInsets` parameter), `KvittScaffold` follows; chart bars top 4, bottom 2 dp (D-097); 221 goldens re-recorded. `FcmMessageBody.fid` in `:data` followed (`09f474f`). @@ -12,31 +14,40 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - Owner answers 2026-10-18: "boende" keeps suggesting `travel` (no change); the 128 px picture size is not needed; OQ-5 "no, hide the button" (implemented by M8c); the account page split bar stays as designed; M2c-pi becomes an owner-run bundle (D-100); the keystore lives at the repository root (D-101); the Gitea push waits for fewer goldens and a history rewrite, no LFS. - M12e outcome (recorded in SPEC v0.13 as D-086 to D-095): E-3 `invite_kind` and `Pairing`, S-2 only after a `partner` pairing (D-086); admin name in E-12 links via `?from=`, passed on by E-4, S-1 help line names the admin (D-087); `SyncSummary.lastAttemptFailed` (D-088); `Rejection.actor` (D-089); `X-Kvitt-App-Version-Name`, migration 0003, server schema 3, S-20 "Kvitt 1.1" (D-090); `NewRecurringTemplate` (D-091); `UpdateCheck` (D-092, revises D-073); `SettleState.untouched` (D-093, revises D-074); D-027 re-stamp in `:data` (D-094); `PushEvent` in `:core:model`, six screen-hosted snackbar hosts, snackbar resource ids, adaptive vector launcher icon (D-095, revises D-071). 128 px picture size: the owner confirmed it is not needed (single 512 size, SPEC §14.2.4). - Compatibility: the M12e APK cannot pair or list devices against a pre-M12e server (DTOs without defaults, by design), so the server is rebuilt and redeployed before the new APK is installed (SPEC §14.8). Old phones on the new server work. -- In flight: M2c-pi bundle `infra/pi/` (D-103 to D-106), M8e (S-19 editor rows, D-107), `agent-docs-v016` (this docs change: SPEC v0.16, D-098, D-099, D-102). Then a one-off history rewrite (drop the superseded PNG blobs; no clone exists) and the first push to `origin` (owner authorised, no confirmation needed), which also runs the Gitea Actions workflow. -- Remote: `origin` on Gitea exists and the SSH key works; the golden reduction (M8c) has landed; the history rewrite and the first push follow this docs pass (owner authorised 2026-10-18, no further confirmation). The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone-drive-token.json`, turned into `rclone.conf`; both git-ignored). +- In flight: `agent-docs-v017` (this docs change: SPEC v0.17, D-103 to D-108), an AC-coverage audit (its report to be committed later as `docs/validation/ac-coverage.md`). Then a one-off history rewrite (drop the superseded PNG blobs; no clone exists) and the first push to `origin` (owner authorised, no confirmation needed), which also triggers the Gitea Actions workflow. +- Remote: `origin` on Gitea exists and the SSH key works; the golden reduction (M8c) has landed; the history rewrite and the first push follow this docs pass (owner authorised 2026-10-18, no further confirmation). The owner created the Gitea secret `kvitt_cicd` and the Drive token (`infra/secrets/rclone-drive-token.json`, turned into `infra/secrets/rclone.conf` with `[gdrive]` `type`, `scope`, `token`; the folder id stays `RCLONE_FOLDER_ID` in `.env`; both git-ignored); the cert-sync key `infra/secrets/ha_ssh_key` exists. - Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test` (from `server/`). Both green on `main` as far as reported. - The UX sprint branch `UX-sprint` and its worktree `~/orca/workspaces/kvitt/UX-sprint` were removed on 2026-10-18 (fully merged). - Importer: the dry run of 2026-10-18 with M12b's mapping reconciled on both real files (Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped). The CSV files stay in `export/` (git-ignored). - Development stack (M2c-dev): running on the Mac, reachable at `http://192.168.1.210:8080`; rebuilt on 2026-10-18 with the M12e server and the `fid` target (a push by `fid` arrived in the M8 test), database at schema 3, data kept. Backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` (before schema 2) and `infra/dev/backups/kvitt-20261018T130443Z.sqlite.gz` (before schema 3) exist (git-ignored). On this 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`. The owner's phone paired with the bootstrap code on 2026-10-18; a scripted partner exists, joined through a second admin device paired with `kvitt-server pair-device` (tokens held by the orchestrator outside the repo). 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`, paired against the dev server for the M8 test on 2026-10-18; the next install carries M8b, M8c and M8d. +- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server for the M8 test on 2026-10-18; the next install carries M8b to M8f. ## 2. Open work (one line each, with who owns it: agent or owner) -- Land `agent-docs-v016` (orchestrator): SPEC v0.16, D-098, D-099, D-102, HANDOFF, `prompts/README.md`, design-spec §3.7, §3.10 and S-22. -- Land the M2c-pi bundle (D-103 to D-106) and M8e (D-107) when they report (orchestrator), then a docs agent for v0.17. -- History rewrite (drop superseded PNG blobs; no clone exists), then the first push to Gitea `origin` (orchestrator; owner authorised, no confirmation needed); the push runs the Gitea Actions workflow (§3 item 3). -- Install the next debug build and have the owner look (§3 item 1); build a signed release for the smoke taps (§3 item 2). +- Land `agent-docs-v017` (orchestrator): SPEC v0.17, D-103 to D-108, HANDOFF, `prompts/README.md`, design-spec §2.11, §3.7 and S-19. +- AC-coverage audit (agent, running): report to `docs/validation/ac-coverage.md`, committed later. +- History rewrite (drop superseded PNG blobs; no clone exists), then the first push to Gitea `origin` (orchestrator; owner authorised, no confirmation needed); the push triggers the Gitea Actions workflow (§3 item 1). +- Install the next debug build and have the owner look (§3 item 2); build a signed release for the smoke taps (§3 item 3). +- Owner runs the M2c-pi bundle on the Kvitt Pi (§3 item 4); record the add-on key variant (A/B) in `infra/README.md` and the router-forward answer. - Cleanup (agents of the owning modules, no plan yet): 20 unused strings R8 found (`editor_next`, `home_notification_rationale`, `settle_empty_settled`, `home_sync_status_tag_*`, …) could be deleted by their modules; optional `localeFilters` en+sv. - Ops request (no plan yet): a `kvitt-server` subcommand for a partner invite, so a scripted partner needs no second admin device. -- Plans (SPEC §11.2 v0.16), in dependency order; prompt `prompts/.md`: - - M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b, M8c, M8d, M9, M12a, M12b, M12c, M12d, M12e, U1, U2, R8 release build: landed. - - Now: M2c-pi bundle, M8e. - - Next: the owner runs the M2c-pi bundle on the Kvitt Pi, after the app is proven (D-100); M10 First release after M2c-pi (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045). +- Plans (SPEC §11.2 v0.17), in dependency order; prompt `prompts/.md`: + - M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12e, U1, U2, R8 release build, M2c-pi bundle: landed. + - Next: the owner runs the M2c-pi bundle on the Kvitt Pi (D-100); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045). ## 3. HUMAN steps pending (relay in chat) -1. Next install on the phone (yes/no each): the title bar sits below the status bar; the quick-row selected badge is whole; the invite-code field cursor stays after the last character; the expense editor shows Konto, Datum, Betalt av, Delning as four rows that do not move when a value changes; "Household" fits its half of the Analytics filter bar. -2. Release build smoke taps: the app opens; S-1 or Home renders; Settings opens and shows the version; add one expense and see it sync. -3. After the first push: tell me whether the Gitea Actions run succeeded. -4. Later, Pi (D-100), after the app is proven: copy the `infra/pi/` bundle to the Kvitt Pi, fill the placeholders in its `.env`, run the README checklist. +1. After the first push: tell me whether the Gitea Actions run ("Build Kvitt images") succeeded and how long it took. +2. Next install on the phone (yes/no each): screen changes are instant, with no fade or slide; plus the earlier checks: the title bar sits below the status bar; the quick-row selected badge is whole; the invite-code field cursor stays after the last character; the expense editor shows Konto, Datum, Betalt av, Delning as four rows that do not move when a value changes; the recurring editor shows Konto, Betalt av, Delning as three such rows; "Household" fits its half of the Analytics filter bar. +3. Release build smoke taps: the app opens; S-1 or Home renders; Settings, About shows the version; add one expense and watch it sync. +4. Pi, after the app is proven: the nine steps of `infra/README.md` "Pi: what the owner does": + 1. Provision: Raspberry Pi Imager, Raspberry Pi OS Lite (64-bit), user, SSH key, time zone; static DHCP lease in the router. + 2. Docker Engine from Docker's Debian arm64 repository; `docker run --rm hello-world` prints "Hello from Docker!". + 3. Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, `authorized_keys`: add the cert-sync key as variant A (restricted `tar` command); if the add-on refuses it, variant B (plain key). Question: which variant was accepted, A or B? + 4. On the Mac: `infra/pi/.env` from `.env.example`, every `CHANGE_ME` replaced; secrets staged into `infra/pi/secrets/`. + 5. `scp` the bundle to the Pi, remove `infra/pi/secrets` on the Mac; on the Pi `ssh-keyscan` the Home Assistant host into `secrets/ha_known_hosts` (at least 1 line). + 6. `./install.sh` on the Pi; expected end "Kvitt is running."; then remove `~/kvitt-pi/secrets`. + 7. Router, WAN, Virtual Server / Port Forwarding: TCP 8443 to the Kvitt Pi's 8443; on the phone with Wi-Fi off, `https://icehack.asuscomm.com:8443/health` shows `"status":"ok"` without a certificate warning. Question: yes/no? + 8. `/opt/kvitt/backup-now.sh` ends with "last-success: "; `/opt/kvitt/bootstrap.sh` prints the admin invite code (first install only). + 9. New versions: `/opt/kvitt/deploy.sh v1.0.N` after the Gitea workflow pushed that tag. ## 4. Owner questions (asked, unanswered) - None. OQ-3 is answered by placeholders (D-100); OQ-5 answered and implemented (D-099). @@ -45,9 +56,9 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - Nothing. ## 6. Numbering -- Decisions: D-001 to D-102 used except D-008 and D-009 (D-103 to D-106 reserved by M2c-pi, D-107 by M8e). Next free: **D-108**. -- Spec version: v0.16 written; next **v0.17** (taken by the M2c-pi and M8e outcomes). -- Server schema version **3** (migration 0003, landed with M12e); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.16. +- Decisions: D-001 to D-108 used except D-008 and D-009. Next free: **D-109**. +- Spec version: v0.17 written; next **v0.18**. +- Server schema version **3** (migration 0003, landed with M12e); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.17. - Sync protocol **1**, push payload **1**. ## 7. How the work is run diff --git a/docs/decisions.md b/docs/decisions.md index 018b541..da05a3c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -27,7 +27,7 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-020 | 2026-10-17 | No bundled font: the app uses the Material 3 default typeface. No licence step. | PLAN-M6 open point (b); avoids a font licence clearance. | SPEC §6.2, §11.2, §13.4; PLAN-M2b, PLAN-M6, PLAN-U1, PLAN-U2 | | D-021 | 2026-10-17 | `sync_mutations` pruning runs daily at 04:00 Europe/Stockholm (DST-aware), deleting rows whose `received_at` is older than 90 days. | PLAN-M4 open point (c); the spec said only "daily". | SPEC §14.1 (rules text, not the DDL), §14.4.9, §11.2; PLAN-M4 | | D-022 | 2026-10-17 | Development infrastructure (M2c-dev, landed `c885aba`). No new CLI installs: Firebase project and Android apps through the Firebase Management REST API with a `gcloud` access token, no Firebase CLI; `rclone` belongs to M2c-pi. The agent may write git-ignored secrets, `google-services.json` included, under `infra/secrets/`; M8 copies it into `android/app/` with `infra/dev/copy-google-services.sh`. Phone health check against the dev server is a HUMAN step. Gate: `docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config`; `shellcheck $(find infra -name '*.sh')`. As built: dev stack without Caddy or TLS, bind-mounted data folder, the whole `infra/secrets/` mounted read-only at `/secrets` (a missing key file means push disabled), env file `infra/dev/.env.example` (the Pi's stays `infra/env.example` for M2c-pi), `kvitt-server healthcheck` used by compose, backups with `sqlite3 .backup` (§10.9). The server must start when `KVITT_TRUSTED_PROXY` does not resolve. Firebase on `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm@kvitt-1337.iam.gserviceaccount.com` with `roles/firebasecloudmessaging.admin` (`roles/firebasemessaging.admin` does not exist). | Owner's tools already on the Mac; M2c-dev report. | SPEC §10 intro, §10.4, §10.7, §11.2, §11.3, §14.7; PLAN-M2c-dev, PLAN-M8 | -| D-023 | 2026-10-17 | M2c-pi: ~~key-based SSH to the Kvitt Pi and the Gitea credentials are HUMAN steps; the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint;~~ (revised by D-100: no SSH from the Mac to the Pis and no agent allowances; the owner runs the `infra/pi/` bundle; the Gitea credentials stay a HUMAN step); the Gitea Actions workflow lives at `.gitea/workflows/`, owned by M2c-pi; secret files on the Pi (`ha_ssh_key`, `ha_known_hosts`, `rclone.conf`) mode 0600 owned by uid 1000, the uid of the `cert-sync` and `backup` containers; adding `gitea.lan` to `insecure-registries` is its own HUMAN step. | PLAN-M2c-pi open points (a) to (c). | SPEC §10.2, §10.3, §11.2, §11.3, §14.7; PLAN-M2c-pi | +| D-023 | 2026-10-17 | M2c-pi: ~~key-based SSH to the Kvitt Pi and the Gitea credentials are HUMAN steps; the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint;~~ (revised by D-100: no SSH from the Mac to the Pis and no agent allowances; the owner runs the `infra/pi/` bundle; the Gitea credentials stay a HUMAN step); the Gitea Actions workflow lives at `.gitea/workflows/`, owned by M2c-pi; secret files on the Pi (`ha_ssh_key`, `ha_known_hosts`, ~~`rclone.conf`~~) mode 0600 owned by uid 1000, the uid of the `cert-sync` ~~and `backup`~~ container (revised by D-105: `backup` runs as uid 10001 and `rclone.conf` is owned by 10001:10001); adding `gitea.lan` to `insecure-registries` is its own HUMAN step. | PLAN-M2c-pi open points (a) to (c). | SPEC §10.2, §10.3, §11.2, §11.3, §14.7; PLAN-M2c-pi | | D-024 | 2026-10-17 | Release keystore: `keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000`, created by the owner ~~and kept outside the repository~~ (was `android/keystore/release.jks`; revised by D-101: at the repository root, git-ignored by `*.jks`); `local.properties` holds its absolute path. | §10.6 did not fix the key parameters (PLAN-M10 H-1); a keystore outside every checkout cannot be committed or deleted with a worktree. | SPEC §10.6, §11.2, §11.3; PLAN-M10 | | D-025 | 2026-10-17 | Production Splitwise import (M11): a backup first; user ids resolved by display name from the database; CSVs copied to the Pi into `/opt/kvitt/import/`, bind-mounted read-only into `server` as `/import`; the import runs as `docker compose run --rm --entrypoint kvitt-import server ...` with the server stopped (`--entrypoint` because the image's entrypoint is `kvitt-server`); CSVs deleted afterwards. The category table stays at `server/crates/kvitt-import/data/splitwise_categories.csv`. | PLAN-M11 open points (a) to (c); PLAN-M9 table location. | SPEC §10.2, §10.8, §11.2, §11.3; PLAN-M2c-pi, PLAN-M9, PLAN-M11 | | D-026 | 2026-10-17 | A 409 `cursor_ahead` on pull is treated as epoch recovery: cursor 0, epoch cleared, every local expense and settlement marked pending with a new `pending_mutation_id`, cycle restarted. | M5 report (`c28c52b`): a cursor ahead of the server means the server lost rows, as after a restore without `rotate-epoch`. | SPEC §14.4.2 | @@ -104,6 +104,12 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-097 | 2026-10-18 | S-11 chart bars have top corners 4 dp and bottom corners 2 dp on the whole bar, replacing top 10, bottom 4. The 2 dp gaps between segments and the clip of the whole bar (not per segment) are unchanged. Assumption recorded: the owner's "4 outer / 2 inner" means top / bottom. Landed by M8b (`f0575d3`) in `KvittShapes` and `Charts.kt`, goldens re-recorded. | First phone test: the owner found the bar tops too round. | SPEC §6.2 (Charts), §13.4; `docs/ux/design-spec.md` §2.9 (shapes table), §3.10 | | D-098 | 2026-10-18 | Golden layout: three variants per component sheet or screen state, `_sv_light`, `_sv_dark`, `_en_light` (English covers string length, dark covers the theme; no `en_dark`), plus `_sv_light_200` for the long states; rendered at 1x density through the Robolectric qualifier `w411dp-hdp-mdpi`, so a full screen is 411 × 891 px. Every committed PNG, the `docs/ux/` mock-ups included, is compressed losslessly with `oxipng -o 4 --strip safe` (`7c453d8`). Landed by M8c (`b481fab`). Revises D-066 (the compose-once configuration context stays). | The owner wants a small repository for the home Gitea server: the goldens went from 1143 PNGs / 131 MB to 865 / 26 MB, the mock-ups from 36.8 to 26.0 MB. | SPEC §6.2 (Screenshot tests), §13.4; `android/README.md` | | D-099 | 2026-10-18 | S-22 About has no open-source licences row: `AboutScreen`'s `onOpenLicences` (and `AboutActions.onOpenLicences`, the `:app` call site) and the string `settings_about_licences` are removed, also from `docs/ux/strings.csv` (577 rows). Landed by M8c (`64f5706`). Closes OQ-5. | Owner answer to OQ-5 on 2026-10-18: no licences screen in v1, hide the button. | SPEC §11.3, §12 (OQ-5), §13.3 (S-22), §13.4; `docs/ux/design-spec.md` S-22 | -| D-100 | 2026-10-18 | No SSH from the Mac to the Pis. M2c-pi produces a self-contained bundle `infra/pi/` (compose file, Caddyfile, env template, install, deploy, bootstrap and backup scripts, a README checklist) that the owner copies to the Kvitt Pi and runs there. The OQ-3 values (Kvitt Pi address, Home Assistant Pi address, SSH port and user, certificate directory, SSH add-on) become placeholders the owner fills in the bundle's `.env`; OQ-3 is answered by this. Where the spec has a Mac script act on the Pi (`infra/deploy.sh`, `infra/publish-apk.sh`), the step becomes a bundle script the owner runs; 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. Agents never need allowances for the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. Revises D-023 and the M2c-pi plan; D-001 stands. | Owner decision 2026-10-18: no agent access to the home servers; the Pi is set up once, by hand, from a checklist. | SPEC §10 intro, §10.1, §10.10, §11.1, §11.2 (M2c-pi, M10), §11.3, §12 (OQ-3); PLAN-M2c-pi | +| D-100 | 2026-10-18 | No SSH from the Mac to the Pis. M2c-pi produces a self-contained bundle `infra/pi/` (compose file, Caddyfile, env template, install, deploy, bootstrap and backup scripts, a README checklist) that the owner copies to the Kvitt Pi and runs there. The OQ-3 values (Kvitt Pi address, Home Assistant Pi address, SSH port and user, certificate directory, SSH add-on) become placeholders the owner fills in the bundle's `.env`; OQ-3 is answered by this. Where the spec has a Mac script act on the Pi (`infra/deploy.sh`, `infra/publish-apk.sh`), the step becomes a bundle script the owner runs; 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. Agents never need allowances for the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. Revises D-023 and the M2c-pi plan; D-001 stands. Fulfilled by D-103 to D-106 (bundle landed `5e027df`). | Owner decision 2026-10-18: no agent access to the home servers; the Pi is set up once, by hand, from a checklist. | SPEC §10 intro, §10.1, §10.10, §11.1, §11.2 (M2c-pi, M10), §11.3, §12 (OQ-3); PLAN-M2c-pi | | D-101 | 2026-10-18 | The release keystore `kvitt-release.jks` (created with the D-024 `keytool` command) lives at the repository root of the main checkout, git-ignored by the `*.jks` pattern of `.gitignore` and never committed; `android/local.properties` references it by absolute path in `kvitt.release.storeFile` with the other three `kvitt.release.*` keys. The owner keeps a copy of the file and its password in their password manager. Revises D-024 ("outside the repository"). | Owner decision 2026-10-18; the `*.jks` ignore rule already keeps the file out of git. | SPEC §10.6, §11.2 (M10), §11.3; PLAN-M10 | | D-102 | 2026-10-18 | S-7 editor options as fixed rows. Below the category quick row, account, date, payer and split are four full-width rows in a fixed order (Konto, Datum, Betalt av, Delning) instead of a wrapping chip row, so no option moves when a value changes. Rows form a segmented group (outer corners 24, inner 8, gap 3) in surface-container-high (secondary-container while the split section is open), each at least 48 dp and tappable in full. A row has a leading element in a 24 dp box, a label (label-large, muted) in a column as wide as the widest label, the value (body-large, one line, ellipsis) and the trailing affordance at the right edge (expand_more/expand_less, swap_horiz for the payer). At ≥ 1.5× font scale the label sits over the value, which takes up to two lines, and every row keeps the two-line height. TalkBack sentences are unchanged. S-9 and S-19 keep their chips (S-19 follows in M8e, D-107). Also: connected buttons get 8 dp side padding so 'Household' fits half of S-11's filter bar at 1x. Landed by M8d (`3c68594`): `EditorOptionRows` in `:core:design`, `KvittDimens.connectedButtonSide`. | Owner finding on S-7, 2026-10-18: the chips moved when a value changed. | SPEC §13.3 (S-7), §13.4; `docs/ux/design-spec.md` §3.7, §3.10 | +| D-103 | 2026-10-18 | The M2c-pi bundle `infra/pi/`: `compose.yaml` (was `infra/compose.yaml`), `.env.example` (was `infra/env.example`), `Caddyfile`, and the on-Pi scripts `install.sh` (idempotent: creates `/opt/kvitt/{secrets,import,apk}`, installs the secrets with their modes, adds `gitea.lan` to `insecure-registries`, sets the volume owners, starts the stack), `deploy.sh [--skip-backup]` (backs up before every deploy, because the Pi cannot tell whether a tag carries a migration; keeps `/opt/kvitt/previous-tag` for rollback), `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, helpers in `lib.sh`. APK path: `infra/publish-apk.sh` on the Mac writes `infra/pi/out/apk/` (`version_code` = `git rev-list --count HEAD`, `version_name` = `YYYY.MM.DD-` in Europe/Stockholm, passed as `-Pkvitt.versionCode` / `-Pkvitt.versionName`; copies `mapping.txt` as `kvitt-.mapping.txt`; refuses a dirty tree and an unsigned APK); the owner `scp`s it to `/opt/kvitt/apk/`; `install-apk.sh` copies it into the `data` volume, writing `latest.json` last and keeping the newest 5; mappings stay outside the volume. `.env` keys grow by `KVITT_REGISTRY_USER`, `KVITT_REGISTRY_TOKEN` (read by `install.sh` only) and the informational `KVITT_PI_HOST`, `KVITT_PI_SSH_USER`. The §11.2 gate becomes `docker compose -f infra/pi/compose.yaml config`. Landed `5e027df`. Fulfils D-100. | M2c-pi report: D-100 left the layout, script names and the image and APK paths to M2c-pi. | SPEC §10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2 (M2c-pi), §11.3, §14.7; `infra/README.md` | +| D-104 | 2026-10-18 | Images `${KVITT_REGISTRY}/kvitt-server`, `kvitt-cert-sync`, `kvitt-backup` with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/`). `.gitea/workflows/build-images.yaml` follows the owner's working example: runner `host` (a Raspberry Pi 3, 1 GB; builds run on the runner itself), Alpine `apk add git docker-cli docker-cli-buildx`, a plain-HTTP buildx builder created once and reused, secret `kvitt_cicd`; triggers on `server/**`, `infra/cert-sync/**`, `infra/backup/**`, `.gitea/workflows/**` and `workflow_dispatch`; tags `v1.MINOR.PATCH` (the next patch after the newest tag) plus `latest`, the git tag pushed afterwards; `--build-arg CARGO_BUILD_JOBS=1` (honoured by `server/Dockerfile` since `965bb5d`; first build duration unknown). `infra/build-images.sh` on the Mac is the fallback (default tag the short SHA; builds locally and prints the push commands when `infra/secrets/registry.env` is absent). Both sidecars schedule with supercronic instead of busybox crond (crond runs no job as non-root, tested); the cert-sync image adds `openssl`. Pinned versions (2026-10-18): `caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af`, `alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6`, openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; Docker Engine from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is 32-bit only), `docker-ce` 5:29.8.2. | M2c-pi report: the owner's example workflow pushes under the owner path with flat image names; busybox crond did not run jobs as a non-root user. | SPEC §10.1, §10.2, §10.3, §12 (OQ-1); `.gitea/workflows/build-images.yaml`, `infra/README.md` "Versions" | +| D-105 | 2026-10-18 | `backup` runs as uid 10001 on the Pi too, so it can open the WAL database (`sqlite3 .backup` needs the `-shm` file the server owns); `/opt/kvitt/secrets/rclone.conf` mode 0600 owned 10001:10001. `cert-sync` stays uid 1000 with `ha_ssh_key`, `ha_known_hosts` 0600 owned by 1000. `backup.sh` uses a temporary copy of the read-only `rclone.conf` so rclone can save refreshed tokens. `install.sh` chowns `/data`, `/data/apk`, `/backups` to 10001 and `/certs` to 1000 once. Reconciles SPEC §10.2; revises D-023 for `rclone.conf`. | M2c-pi report: uid 1000 could not back up the live database without loosening the server's files. | SPEC §10.2, §10.9; `infra/README.md` "Backup uid" | +| D-106 | 2026-10-18 | `sync.sh` always requests the `tar -C -cf - fullchain.pem privkey.pem` stream, so both `authorized_keys` variants (A restricted, B plain) work unchanged; it refuses a key not matching its certificate and a certificate expiring before the installed one; an unreachable Caddy admin API at first start is not a failure (Caddy loads the files when it starts). `backup.sh` uploads every pending `kvitt-*` / `pictures-*` file including leftovers of failed runs, confirms each by name and size with `rclone lsf`, deletes the local copies only after all are confirmed, and writes `last-success` only after that. | M2c-pi report: the add-on's support for `authorized_keys` options is unknown; rehearsal of renew, refuse and failed-upload cases on the Mac. | SPEC §10.5.1, §10.9 | +| D-107 | 2026-10-18 | S-19's options as fixed rows, extending D-102. The recurring template editor replaces its wrapping chips row (Konto · Betalt av · Delning) with `EditorOptionRows`: three full-width rows in the fixed order Konto, Betalt av, Delning, with no Datum row. They look and behave as in S-7: label column, value on one line with an ellipsis, label over value with a two-line value at ≥ 1.5× font scale, and Delning in secondary-container while the split section is open. A row's place and size never depend on its value. The schedule fields stay in the "Schema" section. TalkBack sentences are unchanged. A Robolectric layout test asserts identical row bounds for short and long values at 100 % and 200 %. The chip composables stay in `:core:design` for S-9 (`EditorChipRow`, `DateChip`); `AccountChip`, `PayerChip`, `SplitChip` were removed in `4755fe0`. Landed by M8e (`15397e4`). | Owner finding (D-102): chips moved when a value changed; S-19 had the same chips row. | SPEC §13.3 (S-19); `docs/ux/design-spec.md` §3.7, S-19 | +| D-108 | 2026-10-18 | Navigation is instant. Every destination change (pushed screens, bottom-destination switches, S-7 and S-9 and the other editors, onboarding and the session gate) has no enter, exit, pop or size transition (`EnterTransition.None` / `ExitTransition.None`, no `SizeTransform`, set once on the app's `NavHost`, `NavTransitions` in `:app`). Replaces the Navigation bullet of design-spec §2.11. Motion inside a screen (ripples, state layers, expanding sections, FAB extend/collapse, bottom-sheet open, the seesaw and bunting) is unchanged. Not a contract change. Landed by M8f (`ed1b130`). | Owner finding on the S26, 2026-10-18: the default 700 ms fades made the app feel slow. | SPEC §13.2; `docs/ux/design-spec.md` §2.11 | diff --git a/docs/ux/design-spec.md b/docs/ux/design-spec.md index 8ffaab9..7a1d6a4 100644 --- a/docs/ux/design-spec.md +++ b/docs/ux/design-spec.md @@ -292,7 +292,7 @@ Named motions: * **Settling to zero**: the plank levels (re-tilt spring), then the bunting drops one pennant at a time, 40 ms stagger, each on spatial fast; the card cross-fades to `settled-container` (effects slow). * **Category selection**: the chosen tile cross-fades container → solid (effects default) and the check badge pops (spatial fast); a suggestion change (CS-7) uses the same motion without haptics. * **Saved expense**: the editor closes; the new row is in its month group on return (no extra animation required). -* **Navigation**: pushed screens enter with a horizontal shared-axis transition (30 dp slide + fade, spatial default); tab switches fade through (effects default); S-7 and S-9 enter from the bottom (slide up 10 % + fade). +* **Navigation**: none. Destination changes, tab switches and editors (S-7, S-9) appear at once, with no transition (D-108). * **Reduced motion** (system "Ta bort animationer", `Settings.Global.ANIMATOR_DURATION_SCALE == 0`): all springs become critically damped and immediate, the bunting appears at once, the hero amount does not count. ### 2.12 Icons @@ -533,7 +533,7 @@ Every default category has its own colour; `teal` is shared with the archived ex **`EditorOptionRows`** (S-7, D-102): the account, date, payer and split options as four full-width rows in a fixed order, Konto, Datum, Betalt av, Delning, as a segmented group (outer corners 24, inner 8, gap 3), `surface-container-high`, the Delning row `secondary-container` while the split section is open. Row: min height 48, padding 8 / 16, gap 12: the leading element of the chip table below in a 24 dp box (icons 24), the label (label-large `on-surface-variant`; `editor_account`, `expense_editor_date`, `editor_paid_by`, `editor_split`) in a column as wide as the widest label, the value (body-large, 1 line, ellipsis) and the trailing icon (24) at the right edge; the whole row is one button with the TalkBack sentences below. Rows never move when a value changes. 200 %: the label over the value, the value up to 2 lines, every row keeping the two-line height. -**`EditorChip`** (the chips row of S-9 and S-19): `FlowRow`, gap 6. Chip: min height 36 (48 target), radius full, `surface-container-high`, padding 4 / 10 / 4 / 8, label 14 / 18 sp weight 500 with an optional key prefix in `on-surface-variant` weight 600 ("Betalt av ", "Delning "), leading 18 dp element, trailing 18 dp icon. Selected (the split section is open): `secondary-container`. Chips: +**`EditorChip`** (the chips row). Used by S-9 (date chip) only; S-7 (D-102) and S-19 (D-107) use `EditorOptionRows`. The editor chips row now holds only the date chip, used by S-9 (`DateChip` in `EditorChipRow`); the account, payer and split chips were removed after S-7 and S-19 moved to option rows (D-102, D-107). `FlowRow`, gap 6. Chip: min height 36 (48 target), radius full, `surface-container-high`, padding 4 / 10 / 4 / 8, label 14 / 18 sp weight 500 with an optional key prefix in `on-surface-variant` weight 600 ("Betalt av ", "Delning "), leading 18 dp element, trailing 18 dp icon. Selected (the split section is open): `secondary-container`. Chips: | Chip | Leading | Label | Trailing | Tap | |---|---|---|---|---| @@ -1081,7 +1081,7 @@ Not a screen: the `UpdateBanner` on S-4 (and S-22) and one dialog. **List layout** (app bar: back, "Återkommande utgifter"; column gap 10; FAB "Ny återkommande"): * `SegmentedGroup` of templates (row min 72, top-aligned, padding 14 / 16): `CategoryTile` 40 (muted when paused) · description (title-medium, ellipsis) over "Huset · Anna betalar" (account · payer, body-medium muted) over "Varje månad den 25:e" and "Nästa 25 okt." (body-small muted, two lines; `settings_recurring_every_month_on`, `settings_recurring_next`) or, when paused, a "Pausad" lantern badge (`pause`) + "Varje månad den 12:e" · `MoneyText(Small)` right (70 % opacity when paused). -**Editor layout** (pushed; app bar: close, "Ny återkommande utgift" / "Återkommande utgift", "Spara"; column gap 12): `AmountDisplay` (+ keypad when focused; focused on open for a new template), `DescriptionField`, `CategoryQuickRow` (no suggestion, open point 3), chips Konto · Betalt av · Delning (no Datum), `SplitSection` when open; then a flat section "Schema" (padding 16, gap 10): "Dag i månaden" field (1–31, number keyboard) and "Första månad" (dropdown; a month picker dialog titled with the field label, with a year row and 12 month chips; months before the allowed start disabled, AC-9.8 default) side by side (1 : 1.5); "Sista månad (valfritt)" (placeholder "Ingen slutmånad"); a switch row "Pausad" with "Skapas kl. 06:00 den 25:e varje månad" (body-small muted). Day 29–31: hint "I kortare månader används sista dagen". Editing: hint "Ändringen gäller kommande utgifter." (AC-9.4); after un-pausing: "Månader som passerat under pausen skapas inte i efterhand." (§9.5). Edit mode: "Ta bort" (danger text) at the end. +**Editor layout** (pushed; app bar: close, "Ny återkommande utgift" / "Återkommande utgift", "Spara"; column gap 12): `AmountDisplay` (+ keypad when focused; focused on open for a new template), `DescriptionField`, `CategoryQuickRow` (no suggestion, open point 3), option rows Konto · Betalt av · Delning (`EditorOptionRows`, no Datum; D-107), `SplitSection` when open; then a flat section "Schema" (padding 16, gap 10): "Dag i månaden" field (1–31, number keyboard) and "Första månad" (dropdown; a month picker dialog titled with the field label, with a year row and 12 month chips; months before the allowed start disabled, AC-9.8 default) side by side (1 : 1.5); "Sista månad (valfritt)" (placeholder "Ingen slutmånad"); a switch row "Pausad" with "Skapas kl. 06:00 den 25:e varje månad" (body-small muted). Day 29–31: hint "I kortare månader används sista dagen". Editing: hint "Ändringen gäller kommande utgifter." (AC-9.4); after un-pausing: "Månader som passerat under pausen skapas inte i efterhand." (§9.5). Edit mode: "Ta bort" (danger text) at the end. **States** diff --git a/prompts/README.md b/prompts/README.md index bbef52c..0d6eb3a 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.16). 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 landed (`ab298ab`); SPEC v0.7 landed (`e8ee518`); M12a landed (`6b60ef7`); SPEC v0.8 landed (`7fb9f6f`); M12c landed (`e499107`); M12b landed (`2e2ebe7`); U1 and U2 landed (`16c4d01`, owner approved); SPEC v0.9 landed (`45eecd1`); M12d landed (`bdba9d3`); M6 landed (`3080804`); SPEC v0.10 landed (`7bd0aa1`); M7a landed (`b9b061a`), M7e (`6ac711d`), M7d (`082b8dc`), M7b (`31760f9`), M7c (`1e42f76`), M7f (`a54eb7c`); M6b `:core:design` fixes landed (`3a1bd14`); SPEC v0.11 landed (`d504004`, the M7 and M6b outcomes, D-069 to D-080); M7g feature cleanups landed (`34a2590`, no prompt file; brief by the orchestrator); M8 landed (`369e516`, `00a14a3`), its on-phone test still to run; SPEC v0.12 records the M8 outcomes (D-081 to D-085); the M12e contract pass (no prompt file; brief by the orchestrator) landed (`1470a2f`, `90505a8`); SPEC v0.13 records its outcomes (D-086 to D-095, server schema 3) and landed (`03b7184`); the Vico removal (`2b4e21c`), the FCM `fid` target (`c694f78`) and the Pi image arm64 check (`1e404cc`) landed; SPEC v0.14 records them (D-096); the M8 phone test ran on 2026-10-18 and M8b UI fixes landed (`f0575d3`, D-097) with the `fid` follow-up (`09f474f`); SPEC v0.15 records them and D-100 (M2c-pi as an owner-run bundle) and D-101 (keystore at the repository root). 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); v0.7 the M9 decisions (D-044 to D-046); v0.8 the owner's UX-session decisions (D-047 to D-054) and the milestone group M12; v0.9 D-055 to D-059 and M12d; v0.10 D-060 to D-068; v0.11 D-069 to D-080; v0.12 D-081 to D-085; v0.13 D-086 to D-095; v0.14 D-096; v0.15 D-097, D-100, D-101; after v0.15 M8c (`b481fab`, `64f5706`, `7c453d8`; goldens in three variants at 1x, the S-22 licences row removed), the R8 release build (`57f984f`) and M8d (`3c68594`, S-7 editor rows) landed; SPEC v0.16 records them (D-098, D-099, D-102); the M2c-pi bundle (D-103 to D-106) and M8e (D-107) run now; v0.16 D-098, D-099, D-102. +Dependencies follow SPEC §11.1 and §11.2 (v0.17). 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 landed (`ab298ab`); SPEC v0.7 landed (`e8ee518`); M12a landed (`6b60ef7`); SPEC v0.8 landed (`7fb9f6f`); M12c landed (`e499107`); M12b landed (`2e2ebe7`); U1 and U2 landed (`16c4d01`, owner approved); SPEC v0.9 landed (`45eecd1`); M12d landed (`bdba9d3`); M6 landed (`3080804`); SPEC v0.10 landed (`7bd0aa1`); M7a landed (`b9b061a`), M7e (`6ac711d`), M7d (`082b8dc`), M7b (`31760f9`), M7c (`1e42f76`), M7f (`a54eb7c`); M6b `:core:design` fixes landed (`3a1bd14`); SPEC v0.11 landed (`d504004`, the M7 and M6b outcomes, D-069 to D-080); M7g feature cleanups landed (`34a2590`, no prompt file; brief by the orchestrator); M8 landed (`369e516`, `00a14a3`), its on-phone test still to run; SPEC v0.12 records the M8 outcomes (D-081 to D-085); the M12e contract pass (no prompt file; brief by the orchestrator) landed (`1470a2f`, `90505a8`); SPEC v0.13 records its outcomes (D-086 to D-095, server schema 3) and landed (`03b7184`); the Vico removal (`2b4e21c`), the FCM `fid` target (`c694f78`) and the Pi image arm64 check (`1e404cc`) landed; SPEC v0.14 records them (D-096); the M8 phone test ran on 2026-10-18 and M8b UI fixes landed (`f0575d3`, D-097) with the `fid` follow-up (`09f474f`); SPEC v0.15 records them and D-100 (M2c-pi as an owner-run bundle) and D-101 (keystore at the repository root). 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); v0.7 the M9 decisions (D-044 to D-046); v0.8 the owner's UX-session decisions (D-047 to D-054) and the milestone group M12; v0.9 D-055 to D-059 and M12d; v0.10 D-060 to D-068; v0.11 D-069 to D-080; v0.12 D-081 to D-085; v0.13 D-086 to D-095; v0.14 D-096; v0.15 D-097, D-100, D-101; after v0.15 M8c (`b481fab`, `64f5706`, `7c453d8`; goldens in three variants at 1x, the S-22 licences row removed), the R8 release build (`57f984f`) and M8d (`3c68594`, S-7 editor rows) landed; SPEC v0.16 records them (D-098, D-099, D-102) and landed (`3a3720b`); the M2c-pi bundle (`5e027df`, D-103 to D-106), M8e (`15397e4`, D-107), the Dockerfile `CARGO_BUILD_JOBS` argument (`965bb5d`), M8f (`ed1b130`, D-108, instant navigation) and the chips cleanup (`4755fe0`) landed; SPEC v0.17 records them (D-103 to D-108); what remains of M2c-pi is the owner running the bundle on the Kvitt Pi, then M10; v0.16 D-098, D-099, D-102; v0.17 D-103 to D-108. ## Dependency order and waves @@ -33,9 +33,9 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7 | 3 | M9 Splitwise importer | `M9.md` | M2a, M4 | done (`ab298ab`) | | 3 | M6b Design-system fixes after M7 | none (orchestrator brief) | M7a to M7f | done (`3a1bd14`) | | 4 | M7g Feature cleanups | none (orchestrator brief) | M7a to M7f, M6b | done (`34a2590`) | -| 4 | M8 App integration | `M8.md` | M3, M4, M5, M7a to M7f, M2c-dev | done (`369e516`, `00a14a3`); on-phone test run 2026-10-18; M8b fixes `f0575d3`; M8c `b481fab`, `64f5706`, `7c453d8`; M8d `3c68594`; M8e running (no prompt files; briefs by the orchestrator) | +| 4 | M8 App integration | `M8.md` | M3, M4, M5, M7a to M7f, M2c-dev | done (`369e516`, `00a14a3`); on-phone test run 2026-10-18; M8b fixes `f0575d3`; M8c `b481fab`, `64f5706`, `7c453d8`; M8d `3c68594`; M8e `15397e4`; M8f `ed1b130`; chips cleanup `4755fe0` (no prompt files; briefs by the orchestrator) | | 4 | M12e Contract pass | none (orchestrator brief) | M8, SPEC v0.12 (§11.3 list) | done (`1470a2f`, `90505a8`) | -| 5 | M2c-pi Infra on the Pi (bundle `infra/pi/`, D-100) | `M2c-pi.md` | M2c-dev, M8; runs last, after the app is proven | none needed | +| 5 | M2c-pi Infra on the Pi (bundle `infra/pi/`, D-100) | `M2c-pi.md` | M2c-dev, M8; runs last, after the app is proven | bundle done (`5e027df`, D-103 to D-106); the owner's run on the Pi remains | | 6 | M10 First release | `M10.md` | M2c-pi, M3, M4, M8 | none | | 7 | M11 Import history | `M11.md` | M9, M10 | none |