SPEC v0.34: L-10 implemented (D-156), W0b recorded, web version code in the image build
Build Kvitt images / build (push) Has been cancelled

- AC-3.2, E-16 (§14.3.4), §11.4: the "to implement" notes removed; D-156 says
  implemented in 07c36d2; security review L-10 note: fixed in 07c36d2. ctutils
  0.4.4 (RUSTSEC-2026-0385) in the v0.34 row. Server tests 300, contracts-check 136.
- §15.3: W0b as built (5305f37): shared pieces, no cross-feature imports or web_
  keys, per-locale amount fields, Recharts chunk, Vitest cache off, version code.
  Web gate 609 tests.
- server/Dockerfile: ARG KVITT_VERSION_CODE=1 in the Node stage, passed to
  npm run build; the Gitea workflow computes git rev-list --count HEAD and passes
  it as a build argument (§10.3).
- HANDOFF: main 5305f37 pushed 2026-10-24; next image build, deploy, laptop login,
  browser checks; Web follow-ups closed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-24 11:29:42 +02:00
parent 5305f37038
commit 5d948f9e52
6 changed files with 38 additions and 37 deletions
+8 -6
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.33** (2026-10-24). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
Version **0.34** (2026-10-24). 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.
@@ -132,7 +132,7 @@ The server starts with an empty database. The household is created from the comm
### FR-3 Devices and revocation
* AC-3.1 Settings lists devices: the admin sees all devices of both users; a member sees their own. Each row shows device name, owner, paired date, last seen date, app version (the version name, "Kvitt 1.1", once the device has reported one, else the version code; D-090), and "This device" for the current one.
* AC-3.2 The admin can revoke any device except the one in use (`cannot_revoke_current_device`). A member can revoke their own devices except the one in use. A member who names a device of the other user gets `not_found`, the same answer as for an unknown id, so the answer does not tell whose the device is (D-156, security review L-10; to implement: the server answers `forbidden` there as of v0.33).
* AC-3.2 The admin can revoke any device except the one in use (`cannot_revoke_current_device`). A member can revoke their own devices except the one in use. A member who names a device of the other user gets `not_found`, the same answer as for an unknown id, so the answer does not tell whose the device is (D-156, security review L-10; implemented in `07c36d2`).
* AC-3.3 A revoked device's next request gets HTTP 401 `device_revoked`. The app then deletes its token, its local database and its cached pictures, and shows the "Device removed" screen with a "Pair again" button.
* AC-3.4 A revoked device never receives pushes again.
* AC-3.5 Devices are never deleted from the database; revocation sets `revoked_at` and clears the device's `fcm_token` (D-034).
@@ -933,10 +933,10 @@ File `infra/pi/compose.yaml` (compose project name `kvitt`). The owner copies it
### 10.3 Images, registry and build
* Image names (D-104, D-120): `${KVITT_REGISTRY}/kvitt-server`, `${KVITT_REGISTRY}/kvitt-backup`, with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/<name>`).
* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host`: act_runner 0.6.1 on a Raspberry Pi 3 with 1 GB RAM, with an Alpine job container; the builds run on that Pi itself. `sh` steps, `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with `kvitt_cicd`, a user-level secret of the repository owner `jacobh2` (created by the owner; a user-level secret of the repository owner reaches the repository), 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, and `actions/checkout@v4` (below). The token is interpolated into the login step's `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" | docker login …`), the owner's proven form, because the env-based form (`env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}`) came through empty on act_runner 0.6.1 ("password is empty" on the first run, 2026-10-19); act_runner masks the value in the log (`4f69ae0`, D-120 point 7; this reverses the L-8 fix of `e70fe0d`). `kvitt_cicd` needs only the `write:package` scope, because the git tag push goes through the credentials `actions/checkout` stored (the job's own token) (D-117); the tag push worked on the first successful run (its `v1.0.0` tag is why the next run made `v1.0.1`). Checkout (`3820f9c`): a cone-mode sparse checkout (`sparse-checkout` `server`, `web`, `contracts` (both added by D-137) and `infra/backup`, `sparse-checkout-cone-mode: true`), what the two builds read (the server image builds from the repository root with the allowlist `.dockerignore` since D-137; the backup image from `infra/backup`), which makes the action fetch with `--filter=blob:none`, so only those paths' blobs are downloaded and the Android screenshot PNGs are not checked out; `fetch-depth: 0` stays, because "Determine Version" needs every `v1.0.*` tag and with a shallow fetch `fetch-tags` brings only the tags that point at the fetched tip (the full fetch carries commits and trees, no blobs). The temporary "Diagnose secrets" step (`dd07ca4`) is removed (`3820f9c`). Every `buildx` push passes `--provenance=false` and `--metadata-file`, and a step "Print image digests" prints each pushed image's digest, which the owner compares with the digest the Pi pulled (`docker image inspect`, §10.10; the Pi keeps no digest record since D-125, which revises D-117 point 5). Triggers: a push touching `server/**`, `web/**`, `contracts/**` (the last two added by D-137), `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch` (`infra/cert-sync/**` dropped by D-120). Version `v<MAJOR>.<MINOR>.<PATCH>` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is the Pi's `KVITT_TAG` (§10.2, §10.10); 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; `b4d9467`). Runs on the owner's Pi runner: the first run (2026-10-19) failed at the login with the env-based form; the next ones failed at the login with an empty secret, because the user-level secret was misnamed on the owner's side (found with the diagnostic step, fixed by the owner on 2026-10-19). Run 69 succeeded: login, version `v1.0.0`, `kvitt-backup` built and pushed in 5 min 20 s, `kvitt-server` in 2 h 42 min (the first build on the Pi 3 with `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache), the git tag pushed. The push of the sparse checkout (`3820f9c`) produced `v1.0.1`, the tag the Pi uses. Since `28fa71b` (2026-10-20) the workflow's comments and `infra/build-images.sh`'s closing hint give the compose deploy (`cd ~/kvitt`, set `KVITT_TAG`, `docker compose pull && docker compose up -d`) and `docker image inspect` instead of `/opt/kvitt/deploy.sh` and `/opt/kvitt/deployed-digests`; the workflow's header still says "NOT YET RUN" and that the first build's duration is unknown. Unverified risk: the workflow pushes a git tag, and whether that push re-triggers the path-filtered workflow is not known.
* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host`: act_runner 0.6.1 on a Raspberry Pi 3 with 1 GB RAM, with an Alpine job container; the builds run on that Pi itself. `sh` steps, `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with `kvitt_cicd`, a user-level secret of the repository owner `jacobh2` (created by the owner; a user-level secret of the repository owner reaches the repository), 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, and `actions/checkout@v4` (below). The token is interpolated into the login step's `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" | docker login …`), the owner's proven form, because the env-based form (`env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}`) came through empty on act_runner 0.6.1 ("password is empty" on the first run, 2026-10-19); act_runner masks the value in the log (`4f69ae0`, D-120 point 7; this reverses the L-8 fix of `e70fe0d`). `kvitt_cicd` needs only the `write:package` scope, because the git tag push goes through the credentials `actions/checkout` stored (the job's own token) (D-117); the tag push worked on the first successful run (its `v1.0.0` tag is why the next run made `v1.0.1`). Checkout (`3820f9c`): a cone-mode sparse checkout (`sparse-checkout` `server`, `web`, `contracts` (both added by D-137) and `infra/backup`, `sparse-checkout-cone-mode: true`), what the two builds read (the server image builds from the repository root with the allowlist `.dockerignore` since D-137; the backup image from `infra/backup`), which makes the action fetch with `--filter=blob:none`, so only those paths' blobs are downloaded and the Android screenshot PNGs are not checked out; `fetch-depth: 0` stays, because "Determine Version" needs every `v1.0.*` tag and with a shallow fetch `fetch-tags` brings only the tags that point at the fetched tip (the full fetch carries commits and trees, no blobs). The temporary "Diagnose secrets" step (`dd07ca4`) is removed (`3820f9c`). Every `buildx` push passes `--provenance=false` and `--metadata-file`, and a step "Print image digests" prints each pushed image's digest, which the owner compares with the digest the Pi pulled (`docker image inspect`, §10.10; the Pi keeps no digest record since D-125, which revises D-117 point 5). Triggers: a push touching `server/**`, `web/**`, `contracts/**` (the last two added by D-137), `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch` (`infra/cert-sync/**` dropped by D-120). Version `v<MAJOR>.<MINOR>.<PATCH>` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is the Pi's `KVITT_TAG` (§10.2, §10.10); 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; `b4d9467`). "Determine Version" also sets `VERSION_CODE` to `git rev-list --count HEAD` (the full history is fetched, `fetch-depth: 0`), and the server build passes `--build-arg KVITT_VERSION_CODE=$VERSION_CODE`, the web client's version code (§15.3, v0.34). Runs on the owner's Pi runner: the first run (2026-10-19) failed at the login with the env-based form; the next ones failed at the login with an empty secret, because the user-level secret was misnamed on the owner's side (found with the diagnostic step, fixed by the owner on 2026-10-19). Run 69 succeeded: login, version `v1.0.0`, `kvitt-backup` built and pushed in 5 min 20 s, `kvitt-server` in 2 h 42 min (the first build on the Pi 3 with `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache), the git tag pushed. The push of the sparse checkout (`3820f9c`) produced `v1.0.1`, the tag the Pi uses. Since `28fa71b` (2026-10-20) the workflow's comments and `infra/build-images.sh`'s closing hint give the compose deploy (`cd ~/kvitt`, set `KVITT_TAG`, `docker compose pull && docker compose up -d`) and `docker image inspect` instead of `/opt/kvitt/deploy.sh` and `/opt/kvitt/deployed-digests`; the workflow's header still says "NOT YET RUN" and that the first build's duration is unknown. Unverified risk: the workflow pushes a git tag, and whether that push re-triggers the path-filtered workflow is not known.
* Script `infra/build-images.sh [tag]` on the Mac is the fallback (D-104): builds the two 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 (the lock check shared through `tools/gradle-lock.sh`, `2fac1ec`). Works with Docker buildx and with Podman.
* Where images are built (owner, 2026-10-24, D-156): on the Pi 3 runner through the workflow above for now; whether to move the builds is decided after the result of the run then in progress, and `infra/build-images.sh` stays the fallback. The runner Pi's disk (15 GB) reached 11 GB during builds, so the owner runs `docker builder prune -af` on it between builds; that can drop the BuildKit cache later runs reuse, so the next server build may be a full one.
* Web client (D-132, as built D-137, `50a0d67`): the server image's build context is the repository root (`docker build -f server/Dockerfile .`) with an allowlist `.dockerignore` at the root: everything is excluded except `server`, `web` and `contracts`, and inside them `server/target`, `server/data`, `web/node_modules`, `web/dist`, SQLite and `.db` files, APKs, `.pem` files, `.env` files and `.DS_Store` are excluded too, so the Android project, the docs, the goldens, worktrees and secrets never enter the context; `server/.dockerignore` is removed (`infra/backup/Dockerfile` builds from its own directory and does not read the root file). `server/Dockerfile` has three stages: (1) `web`: `docker.io/library/node:24.22.1-bookworm-slim` pinned by the digest of its multi-arch index (Node 24, the Active LTS line on 2026-10-21), on `$BUILDPLATFORM` (the output is static files, so an amd64 build never emulates npm), `npm ci` with a BuildKit npm cache, then `npm run build` in `/repo/web` beside `/repo/contracts` (the web build may read `../contracts`), failing unless `dist/index.html` exists; a `web/` without `package.json` and `package-lock.json` fails the build by design; (2) the Rust build stage, which copies the web stage's `dist` to `/out/web`, so it orders the two builds and npm and rustc never run at once on the 1 GB Pi 3 runner (BuildKit would otherwise run the independent stages in parallel; a web-only change re-runs the cargo step, which finds nothing to compile); `CARGO_BUILD_JOBS=1` stays; (3) the runtime stage, which copies `/out/web` to `/app` (owned by root, read-only for uid 10001; `KVITT_WEB_DIR` §10.4, serving §15.4). The workflow builds with context `.` and `file: server/Dockerfile`, its sparse checkout and path triggers add `web` and `contracts` (above); `infra/build-images.sh` builds `kvitt-server` with context `.`; `infra/dev/compose.yaml` has no build section, so `infra/dev/build.sh` builds with the repository root as context (`KVITT_SERVER_BUILD_CONTEXT` still overrides it). The Pi runner builds the Node stage too (its duration on the Pi 3 not yet measured); the web client adds no image and no service.
* Web client (D-132, as built D-137, `50a0d67`): the server image's build context is the repository root (`docker build -f server/Dockerfile .`) with an allowlist `.dockerignore` at the root: everything is excluded except `server`, `web` and `contracts`, and inside them `server/target`, `server/data`, `web/node_modules`, `web/dist`, SQLite and `.db` files, APKs, `.pem` files, `.env` files and `.DS_Store` are excluded too, so the Android project, the docs, the goldens, worktrees and secrets never enter the context; `server/.dockerignore` is removed (`infra/backup/Dockerfile` builds from its own directory and does not read the root file). `server/Dockerfile` has three stages: (1) `web`: `docker.io/library/node:24.22.1-bookworm-slim` pinned by the digest of its multi-arch index (Node 24, the Active LTS line on 2026-10-21), on `$BUILDPLATFORM` (the output is static files, so an amd64 build never emulates npm), `npm ci` with a BuildKit npm cache, then `npm run build` in `/repo/web` beside `/repo/contracts` (the web build may read `../contracts`), failing unless `dist/index.html` exists; the stage has no `.git`, so `ARG KVITT_VERSION_CODE=1` is passed to `npm run build` as the environment variable `KVITT_VERSION_CODE`, the web's version code (§15.3; default 1, the workflow passes the commit count, v0.34); a `web/` without `package.json` and `package-lock.json` fails the build by design; (2) the Rust build stage, which copies the web stage's `dist` to `/out/web`, so it orders the two builds and npm and rustc never run at once on the 1 GB Pi 3 runner (BuildKit would otherwise run the independent stages in parallel; a web-only change re-runs the cargo step, which finds nothing to compile); `CARGO_BUILD_JOBS=1` stays; (3) the runtime stage, which copies `/out/web` to `/app` (owned by root, read-only for uid 10001; `KVITT_WEB_DIR` §10.4, serving §15.4). The workflow builds with context `.` and `file: server/Dockerfile`, its sparse checkout and path triggers add `web` and `contracts` (above); `infra/build-images.sh` builds `kvitt-server` with context `.`; `infra/dev/compose.yaml` has no build section, so `infra/dev/build.sh` builds with the repository root as context (`KVITT_SERVER_BUILD_CONTEXT` still overrides it). The Pi runner builds the Node stage too (its duration on the Pi 3 not yet measured); the web client adds no image and no service.
* `server` image: multi-stage (since D-137 the Node `web` stage above runs first; the stages below are the Rust build and the runtime). Stage 1 `rust:<version>-bookworm`, where `<version>` is the exact Rust release pinned in `server/rust-toolchain.toml` (the current stable when M2a pins it; the two always change together), builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) pinned by digest, with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. The Dockerfile selects the Rust target from `TARGETARCH`: `arm64` (`aarch64-unknown-linux-gnu`) is the shipped target; `amd64` (`x86_64-unknown-linux-gnu`) also builds, emulated on the Mac, for local use only. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (`infra/dev`, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (`docs/spikes/pi-image-arm64.md`, landed `3e6c337`): 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.
* `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`.
* The backup sidecar schedules with supercronic instead of busybox `crond` (D-104): it runs as a non-root user, and busybox `crond` started as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedule is unchanged (`30 3 * * *`).
@@ -1389,7 +1389,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
Reports in `docs/validation/` are deliverables (`CLAUDE.md`); they carry aggregates only and record the commit they were taken at. The release reports `first-release.md` and `restore-drill.md` (M10, AC-18.2) are still to be written.
* `docs/validation/ac-coverage.md`, the AC-coverage audit taken at `956908a` (`ed1b130` before the history rewrite; SPEC v0.16), read-only: 270 rows, 260 Done, 5 Partial, 1 Missing, 4 not verifiable without a device. Verdict: feature complete in code; not yet complete against §4's acceptance rule. Its two mismatches in shipped behaviour are fixed by M12f: S-16 opens the lost change read-only (D-109) and S-22 reads the server version from `/health` (D-110); M12f also added tests for the §9.2 effect cases, AC-2.9, the 429 retry, D-040 refusals and WorkManager unique work. Still open: the AC-18.2 restore drill (Missing), AC-20.1 observed on the Pi (M10), and a `KvittWorkerFactory` test. Observed 2026-10-24 (D-156): AC-18.1, the first backup on the Pi with its snapshot pair in the Drive folder (verified by the owner); AC-16.3, the real `PackageInstaller` install on both phones (release 140), once Samsung Auto Blocker was off; the editor keyboard fix (S-19, S-15, S-6, `05ec3f3`) on the device.
* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device): owner answer 2026-10-24, `not_found` (D-156; to implement, E-16). Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (`96e4cbd`, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-character `fcm_token` limit (D-114) confirmed. H-1 had to be fixed before the server was opened to the internet (fixed, `725901c`). D-120 (2026-10-19): L-1's redaction and D-117's headers now live in NPM's Custom Nginx Configuration (§10.5.2); L-2 and M-2 are moot (no Caddy container, no Home Assistant SSH key); the L-8 fix is reversed (the token is interpolated in the run line again, masked by act_runner, §10.3); I-5's check is the conntrack check of §10.10. The review carries dated notes under those findings.
* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device): owner answer 2026-10-24, `not_found` (D-156), fixed in `07c36d2` (E-16). Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (`96e4cbd`, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-character `fcm_token` limit (D-114) confirmed. H-1 had to be fixed before the server was opened to the internet (fixed, `725901c`). D-120 (2026-10-19): L-1's redaction and D-117's headers now live in NPM's Custom Nginx Configuration (§10.5.2); L-2 and M-2 are moot (no Caddy container, no Home Assistant SSH key); the L-8 fix is reversed (the token is interpolated in the run line again, masked by act_runner, §10.3); I-5's check is the conntrack check of §10.10. The review carries dated notes under those findings.
---
@@ -2382,7 +2382,7 @@ The example (`contracts/examples/E-7-me/response.json`) has `"public_url": "http
**E-15 `GET /devices`** (device). 200 `{ "devices": [Device, ...] }` ordered by `created_at`, revoked included (with `revoked_at`).
**E-16 `DELETE /devices/{device_id}`** (device). 204. Revoking an already revoked device is 204. Errors: `not_found` (an unknown id, or, for a member, a device of the other user: D-156, security review L-10), `cannot_revoke_current_device`. To implement (D-156): the server still answers `forbidden` for a member naming the other user's device, and `contracts/openapi.yaml` still lists 403 for E-16; the change drops both together (a contract change, §14.8).
**E-16 `DELETE /devices/{device_id}`** (device). 204. Revoking an already revoked device is 204. Errors: `not_found` (an unknown id, or, for a member, a device of the other user: D-156, security review L-10), `cannot_revoke_current_device`.
**E-17 `PUT /devices/current/push-token`** (device). Request `{ "fcm_token": string? }` (the key is required, its value nullable; null clears). A string must be non-empty: `""` is `invalid_request` (D-034); more than 512 characters after trimming is `invalid_request` with field `fcm_token` too (D-114, security review L-4). 204. Errors: `invalid_request`.
@@ -3022,6 +3022,7 @@ Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `w
* **Language.** Swedish when the browser's preferred language is Swedish, English otherwise (G-8); W-13 offers a choice that overrides it in this browser (`localStorage`).
* **Money and dates.** Integer öre everywhere, formatting per §9.8 (the web formats with the same rules and the same outputs as the app for the same input). The money functions of §14.5.2 (`computeSplit`, `computeBalances`, `proposeAllocation`, `validateSettlement`, `overSettledAccounts`) exist in TypeScript and pass `contracts/test-vectors/*.json` for those functions in a Vitest test; `resolveRange` and `spendSeries` (§14.5.4) pass their vectors (W4), the category suggestion of §9.11 passes `suggest_category.json` (W2). The rules live in `kvitt-domain`, `:domain` and the web's ports of them; the vectors keep the three equal (G-1).
* **Source layout** (agreed for W0 to W5): `web/src/app/` (entry, providers, router with every route, layout shell, keyboard shortcuts), `web/src/api/` (generated schema, client, token store, household snapshot and writes, §15.5), `web/src/i18n/`, `web/src/design/` (tokens, global styles), `web/src/components/` (shared components), `web/src/lib/` (formatting, the money functions under `lib/domain/`, utilities), `web/src/features/<name>/` (one folder per feature plan, each with its own pages, components, tests and strings). Routes: `/app/` Home, `/app/login`, `/app/accounts/:id`, `/app/expenses/new`, `/app/expenses/:id`, `/app/settle`, `/app/settlements/:id`, `/app/analytics`, `/app/analytics/:groupKind/:groupId`, `/app/insights`, `/app/search`, `/app/settings/*`; sub-routes and query parameters are the owning plan's.
* **As built (W0b, `5305f37`).** Shared web pieces live once in `web/src/components` (AccountTile, PageStates, Timeline, OverflowMenu, SwitchRow, `editor/`), `lib` (categories, timeline, accountColors), `api` (errors, invites) and `app` (useToday). A feature never imports another feature (`catalog.test.ts` also fails when a feature uses another feature's `web_` key). Amount fields follow the page language: sv `,` or `.` decimal with space grouping, en `.` decimal with `,` grouping. Recharts is its own chunk loaded by the analytics pages only (vendor 516 → 132 kB). The Vitest cache is off. The web's version code is the git commit count at build time, else `KVITT_VERSION_CODE`, else 1.
* **The web gate** (the third gate, beside the Android and server gates): `npm run check` in `web/`, which runs `tsc --noEmit`, `eslint .`, `vitest run`, `vite build` and the generated-types freshness check (it fails when `web/src/api/schema.d.ts` differs from what `openapi-typescript` generates from `contracts/openapi.yaml`). A change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` in the same landing, by the agent that changes the contract; it is the one file under `web/` that a contract-change plan may write.
### 15.4 Build and serving (D-132)
@@ -3133,3 +3134,4 @@ Plans with disjoint paths (§14.7), one Opus agent each in its own worktree. W0
| 0.31 | 2026-10-21 | Android Insights on S-11 (W6, `1c9856b`; Android gate green, which also fixes the E-34/E-35 example tests red since `50a0d67`). D-141 UI contract: `:core:model` `InsightsRepository` with `trends(months: 1..24, measure)` and `forecast(measure)`, both `OnlineResult` (`Offline` without a request, `Failed("network")` when unreachable); types `SpendTrends`, `SpendTrend`, `TrendPoint`, `CategoryTrend` (`category: Category?`), `SpendForecast`, `AccountForecast` (`account: Account?`), `ForecastMonth`, named after the E-34/E-35 fields; names resolved from Room, archived and deleted included, unknown id → null; nothing stored; `:data` `AnalyticsDtos.kt`, `InsightsRepositoryImpl`, two `KvittApi` calls; fakes `FakeInsightsRepository`, `InsightsScenarios`, `insightsFailure` (§14.2.4). D-142 placement and loading: an "Insights" ("Insikter") section below S-11's local analytics, following S-11's measure toggle, always 6 whole months (E-34's default); loads when S-11 opens, when the measure changes to one not yet loaded, when the phone comes online and on "Försök igen"; data kept in memory per measure while S-11 is open; the last data stays visible under the offline or error banner; S-11's offline analytics unchanged (AC-14.1) (§13.3 S-11, design-spec S-11). D-143 display rules: the household chart and a per-category chart on D-128's `SpendLineChart` with the server's average and moving average; categories in the server's order, the open one defaults to the largest total; unknown category ids skipped; one forecast card per account (archived badge) with the current month so far and two months as "Recurring + average = forecast"; an all-zero card shows one line "No recurring expenses and no recent spending" (design-spec S-11, §3.10). `strings.csv` 588 rows (16 `analytics_insights_*` keys, 19 rows) (§13.4). Goldens `s11_insights_<state>` for 13 states in three variants plus `s11_insights_long_sv_light_200`. |
| 0.32 | 2026-10-21 | The web feature plans W1 to W5 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; web gate on `main` 59 files, 532 tests at `1aecf51`, 598 at `83f5293` with the `suggest_category` vectors) and the update install-failure fix (`d686ee0`). D-144: the preset account travels as `?account=<id>` for `/app/expenses/new` and `/app/settle`, a payment being edited as `/app/settle?settlement=<id>`; builders in `features/accounts/links.ts` and `features/settle/links.ts` until W0 adds them to `routes.ts`. D-145: web Home's empty state shows the "Du och <partner> är med" card in place of the balance header; the archived section's open state lasts for the page session in memory. D-146: CSV export format (UTF-8 with BOM, `;`, CRLF, RFC 4180 quoting, "1234,50" in every language, ISO dates, header in the page language, one share column per user, formula cells prefixed `'`, `kvitt-utgifter-<date>.csv` / `kvitt-expenses-<date>.csv`). D-147: AC-8.6's "on this phone" is this browser (`localStorage`); search filters in the URL (amounts in öre). D-148: web expense editor: rejections and failures in a banner with S-16's text and "Visa nuvarande version", the server's row into the snapshot, typed values kept; "Utgiften sparades" after edits too; the discard dialog on `Esc` and in-app navigation; browser controls in place of S-7's keypad, ratio slider and summary row; shared fields in `web/src/components/editor/`; the keyword table imported raw (`server.fs.allow`). D-149: W-7 shows the §14.5.3 allocation errors as you type. D-150 unused. D-151: a bar opens `/app/analytics/bucket/<key>`; the filter in the query string (`range`, `from`, `to`, `interval`, `measure`, `group`, `bucket`), replaced by W-10's own period changes. D-152: chart SVGs hidden from screen readers, bar labels as keyboard stops, "Visa som tabell"; W-11 offers 3, 6 or 12 months. D-153: W-18's route is `/app/settings/invite` (`/invites` still opens it); W-15 and W-16 editor routes. D-154: the partner's first-login step is an intro on W-14 remembered in `kvitt.profileStepDone` (the device id); W-19's fallback reads the server-version header of a fresh `GET /me` (§15.3, §15.6, §15.7, AC-8.6). D-155 (`d686ee0`): a refused or failed install shows "Kunde inte installera uppdateringen" with the reason (blocked by the security settings, or the installer's message) and "Försök igen", which downloads again; a declined dialog returns to `Available`; `UpdateState.Failed` gains `reason: UpdateFailure` (`Download`, `Blocked`, `Install(detail)`) (new AC-16.7, §13.3 S-17, §14.2.4; design-spec §3.6, S-17, S-22). `strings.csv` 590 rows (`update_install_failed`, `update_install_blocked`). Goldens `S-17-install-blocked`, `S-17-install-failed`, `s22_about_install_blocked` new, `update_banner` re-recorded. contracts-check 136. |
| 0.33 | 2026-10-24 | Owner answers and phone observations of 2026-10-24 (D-156) and the small-edits working rule (D-157). D-156: OQ-6 answered, no confirm step on S-1 for an unusual server (§12); security review L-10 answered, a member naming the other user's device gets `not_found` (AC-3.2, §8.4, E-16; to implement, the server answers `forbidden` today); no host firewall rule limiting port 8080 to `HA_HOST` for now (§10.2, deferred); `mem_limit` kept and advisory on the Pi (§10.2); images keep being built on the Pi 3 runner until the current run's result, `infra/build-images.sh` the fallback, `docker builder prune -af` between builds (runner disk 11 of 15 GB, §10.3); Samsung Auto Blocker must be off for the in-app install (AC-16.3); AC-18.1 verified by the owner, AC-16.3 and the editor keyboard fix observed on the phones, both phones on release 140 (AC-18.1, §11.4); the web client is first used from the laptop browser (§15.2). D-157: small edits by a Haiku or Sonnet agent directly in the main checkout, committed on `main` by the orchestrator (working rule, `CLAUDE.md`). `infra/README.md` "Pi: what the owner does" gains the prune and Auto Blocker notes. contracts-check 136. |
| 0.34 | 2026-10-24 | L-10 implemented (`07c36d2`, D-156) and the web shared follow-ups (W0b, `5305f37`), no new decision. L-10: a member naming the other user's device gets E-16's `not_found`, the same body as an unknown id; `contracts/openapi.yaml` drops E-16's 403 and `web/src/api/schema.d.ts` is regenerated (contract change, §14.8); the "to implement" notes of AC-3.2, E-16 (§14.3.4) and §11.4 removed; `Cargo.lock` bumps `ctutils` 0.4.3 → 0.4.4 (RUSTSEC-2026-0385, published 2026-10-23, which failed `cargo audit` on `main`). Server tests 300; contracts-check 136. W0b: shared web pieces once in `components`, `lib`, `api` and `app`, no feature imports another (`catalog.test.ts` also checks `web_` keys); amount fields per page language; Recharts in its own chunk (vendor 516 → 132 kB); Vitest cache off; Testing Library's `findBy`/`waitFor` limit 5 s in `web/src/test/setup.ts` (load on the Mac during parallel agents); the version code from the commit count, else `KVITT_VERSION_CODE`, else 1 (§15.3). Web gate 609 tests. Image build: `server/Dockerfile`'s Node stage takes `ARG KVITT_VERSION_CODE=1` into `npm run build`, and the Gitea workflow passes `--build-arg KVITT_VERSION_CODE=<git rev-list --count HEAD>` (§10.3). |