SPEC v0.37: image builds on the Mac, v1.0.3 deployed, web client verified (D-211)

infra/build-images.sh on the Mac with Podman is the normal image build, the
Gitea workflow on the Pi 3 runner the fallback (revises D-156 point 5); tag
rule, registry port 80 from the Mac (the Pi's .env keeps gitea.lan:3001),
Podman insecure-registry files, the script fixes f15fa23 and 8fa9198.
v1.0.3 on the Kvitt Pi; the owner verified the web client from the laptop
browser and S-11's Insights (SPEC §10.3, §10.10, §11.2 to §11.4, §15;
infra/README.md; HANDOFF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-24 13:14:00 +02:00
parent 8b620c8c6a
commit ae73f58079
4 changed files with 83 additions and 52 deletions
+17 -14
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.36** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**.
Version **0.37** (2026-10-24). Schema version **4**. 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.
@@ -1029,9 +1029,9 @@ 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`). "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.
* The Gitea Actions workflow (D-104; the fallback since D-211, see the Mac build below): `.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, the normal path since D-211 (the fallback under D-104 and D-156): 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-211, revises D-156 point 5): on the owner's Mac with Podman, `infra/build-images.sh v1.0.N`; the Gitea workflow on the Pi 3 runner stays the fallback (about three hours per run and disk pressure on its 15 GB card; the owner cancels runs it queues). Tag rule: `git fetch --tags`, then the next `v1.0.N` after the highest `v1.0.*` tag on `origin`; the images carry that tag and `latest`, and the same tag is pushed as a git tag on the built commit (`git tag v1.0.N && git push origin v1.0.N`), so the workflow's own numbering continues after it (`v1.0.3` = `8fa9198`, the first Mac build). Registry from the Mac: `gitea.lan` on port 80, plain HTTP; `infra/secrets/registry.env` (git-ignored) holds `KVITT_REGISTRY=gitea.lan/jacobh2`, `KVITT_REGISTRY_USER` and a `write:package` token as `KVITT_REGISTRY_TOKEN`. The Pi's own `.env` keeps `KVITT_REGISTRY=gitea.lan:3001/jacobh2`, the port it reaches the same registry on locally; both name the same images. Podman marks the registry insecure in two places, `~/.config/containers/registries.conf.d/kvitt.conf` on the Mac and `/etc/containers/registries.conf.d/kvitt.conf` inside the Podman machine, each `[[registry]]` `location = "gitea.lan"` `insecure = true` (owner's allowance 2026-10-24 for these files outside the repository). Two script fixes made the Mac path work: the server build gets `--build-arg KVITT_VERSION_CODE=<git rev-list --count HEAD>` as the workflow passes it (`f15fa23`), and the engine test captures `docker version` before matching it (`8fa9198`: under `pipefail`, `grep -q` closed the pipe early, `docker version` died of SIGPIPE and the script chose docker over Podman). The script prints no image digests, so the digest comparison of §10.10 applies to workflow builds. If the fallback runs, the owner still runs `docker builder prune -af` on the runner Pi between builds (its disk reached 11 of 15 GB); that can drop the BuildKit cache, so the next server build there 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; 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`.
@@ -1351,7 +1351,7 @@ Restore (step by step in `infra/RESTORE.md`, run on the Pi in `~/kvitt` as the o
* Where: every command on the Pi in this section is run by the owner in `~/kvitt` (`/home/jacobhagstedt/kvitt`) with plain `docker compose` (D-125, revises D-103's scripts); the owner's operations table is in `infra/README.md` "Pi: what the owner does", Operations. Agents prepare and check the project on the Mac and never connect to the Pi (D-100).
* Status and logs: `docker compose ps`; `docker compose logs --tail 100 <service>`; NPM's own log files are inside NPM on the Home Assistant Pi (§10.5.2).
* Disk: `df -h ~` and `du -sh ~/kvitt/*`. SQLite is a few MB; pictures under 1 MB; APKs about 10 MB each since R8 (§10.6), older ones removed by hand.
* Deploy (upgrade), after the Gitea workflow pushed a `v1.0.N` tag (or `infra/build-images.sh` printed one): `docker compose exec backup /backup.sh` first, continuing only after `backup: ok`, on every deploy, because the Pi cannot tell beforehand whether a tag carries a migration (D-103, kept by D-125); then set `KVITT_TAG` in `.env` (`sed -i 's/^KVITT_TAG=.*/KVITT_TAG=v1.0.N/' .env`, noting the previous tag), `docker compose pull && docker compose up -d`, and check `docker compose ps` and `curl -s localhost:8080/health`. Migrations run on start; a failed migration exits non-zero and the container restarts in a loop.
* Deploy (upgrade), after `infra/build-images.sh v1.0.N` on the Mac pushed the images (D-211; or the Gitea workflow, the fallback, pushed a `v1.0.N` tag): `docker compose exec backup /backup.sh` first, continuing only after `backup: ok`, on every deploy, because the Pi cannot tell beforehand whether a tag carries a migration (D-103, kept by D-125); then set `KVITT_TAG` in `.env` (`sed -i 's/^KVITT_TAG=.*/KVITT_TAG=v1.0.N/' .env`, noting the previous tag), `docker compose pull && docker compose up -d`, and check `docker compose ps` and `curl -s localhost:8080/health`. Migrations run on start; a failed migration exits non-zero and the container restarts in a loop.
* Rollback: set the previous `KVITT_TAG` in `.env` and `docker compose pull && docker compose up -d` (no `previous-tag` file and no `--skip-backup` since D-125). Migrations are forward-only, so if a migration had partially applied, restore the pre-deploy backup (§10.9, `infra/RESTORE.md`); sqlx runs each migration in a transaction, so this should not happen.
* Bootstrap and invites: `docker compose exec server kvitt-server bootstrap` (first install only: the admin invite code, link and expiry of FR-1); once the admin is paired, the partner's invite `docker compose exec server kvitt-server invite-partner`; an extra device `docker compose exec server kvitt-server pair-device --user <user_id>` (§10.4).
* Memory check: `docker stats --no-stream` after the release milestone; server RSS must be under 100 MB with both phones syncing.
@@ -1469,17 +1469,17 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| 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 of the M2c-pi compose project (D-100, D-125); 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 |
| W0 | Web foundation (D-131, D-134) | Landed `2ae65a5` (2026-10-21). `web/`: Vite, React 19, TypeScript strict, React Router 7, TanStack Query 5, `openapi-typescript` types in `web/src/api/schema.d.ts` and `openapi-fetch`, Vitest and Testing Library, ESLint and Prettier; design tokens from `docs/ux/design-spec.md` §2 as CSS custom properties; layout shell (sidebar, bottom bar under 720 px), router with every route of §15.3, keyboard shortcuts and W-20; login (W-1), token store and 401 handling; API client and household snapshot (§15.5); i18n (sv, en); formatting (§9.8); shared components; the money functions of §14.5.2 passing their vectors; `npm run check` | Web gate | none | SPEC v0.28 |
| W1 | Web: home and accounts | Landed `934eee7` (2026-10-21, D-144, D-145, SPEC v0.32). W-2, W-3, W-4 in `web/src/features/{home,accounts}/` | Web gate | Look at Home and an account in a browser against the development stack (yes/no) | W0 |
| W2 | Web: expenses, search and export | Landed `a65ab71` (2026-10-21, D-146 to D-148, SPEC v0.32; the shared editor fields in `web/src/components/editor/`). W-5 with the §9.11 suggestion (`suggest_category.json`, in the vector test since `83f5293`), W-6 with the timeline, W-12 with the CSV export, in `web/src/features/{expenses,search,export}/` | Web gate | Add, edit and delete an expense in a browser; open an exported CSV in Numbers (yes/no) | W0 |
| W3 | Web: settle up | Landed `f9a9bfb` (2026-10-21, D-144, D-149, SPEC v0.32). W-7, W-8 in `web/src/features/settle/` | Web gate | Record and delete a settlement in a browser (yes/no) | W0 |
| W4 | Web: analytics and insights | Landed `1aecf51` (2026-10-21, D-151, D-152, SPEC v0.32). W-9, W-10 with the D-128 line chart, W-11 on the D-133 endpoints (E-34, E-35); `resolveRange` and `spendSeries` passing their vectors (`features/analytics/domain/analytics.vectors.test.ts`); in `web/src/features/analytics/` | Web gate | Look at the analytics pages in a browser (yes/no) | W0; W-11 needs W7 |
| W5 | Web: settings | Landed `3ebe57d` (2026-10-21, D-153, D-154, SPEC v0.32). W-13 to W-19 in `web/src/features/settings/` | Web gate | Pair a browser, see it on the phone's Devices, revoke it from the phone (yes/no) | W0 |
| W6 | Android: Insights | Landed `1c9856b` (2026-10-21, D-141 to D-143, SPEC v0.31; Android gate green, 40 `s11_insights_*` goldens). An Insights section on S-11 from the D-133 endpoints, online only (§9.9): a UI-contract change (§14.8) in `:core:model`, `:data`, `:core:fakes` and `:feature:analytics`, with goldens | Android gate | Look at S-11's Insights on the phone (yes/no) | W7 landed, its image deployed |
| W1 | Web: home and accounts | Landed `934eee7` (2026-10-21, D-144, D-145, SPEC v0.32). W-2, W-3, W-4 in `web/src/features/{home,accounts}/` | Web gate | Look at Home and an account in a browser against the development stack (yes/no); done on the Pi 2026-10-24 (D-211) | W0 |
| W2 | Web: expenses, search and export | Landed `a65ab71` (2026-10-21, D-146 to D-148, SPEC v0.32; the shared editor fields in `web/src/components/editor/`). W-5 with the §9.11 suggestion (`suggest_category.json`, in the vector test since `83f5293`), W-6 with the timeline, W-12 with the CSV export, in `web/src/features/{expenses,search,export}/` | Web gate | Add, edit and delete an expense in a browser; open an exported CSV in Numbers (yes/no); done 2026-10-24 (D-211) | W0 |
| W3 | Web: settle up | Landed `f9a9bfb` (2026-10-21, D-144, D-149, SPEC v0.32). W-7, W-8 in `web/src/features/settle/` | Web gate | Record and delete a settlement in a browser (yes/no); done 2026-10-24 (D-211) | W0 |
| W4 | Web: analytics and insights | Landed `1aecf51` (2026-10-21, D-151, D-152, SPEC v0.32). W-9, W-10 with the D-128 line chart, W-11 on the D-133 endpoints (E-34, E-35); `resolveRange` and `spendSeries` passing their vectors (`features/analytics/domain/analytics.vectors.test.ts`); in `web/src/features/analytics/` | Web gate | Look at the analytics pages in a browser (yes/no); done 2026-10-24, Insights included (D-211) | W0; W-11 needs W7 |
| W5 | Web: settings | Landed `3ebe57d` (2026-10-21, D-153, D-154, SPEC v0.32). W-13 to W-19 in `web/src/features/settings/` | Web gate | Pair a browser, see it on the phone's Devices, revoke it from the phone (yes/no); done 2026-10-24 (D-211) | W0 |
| W6 | Android: Insights | Landed `1c9856b` (2026-10-21, D-141 to D-143, SPEC v0.31; Android gate green, 40 `s11_insights_*` goldens). An Insights section on S-11 from the D-133 endpoints, online only (§9.9): a UI-contract change (§14.8) in `:core:model`, `:data`, `:core:fakes` and `:feature:analytics`, with goldens | Android gate | Look at S-11's Insights on the phone (yes/no); done 2026-10-24 against `v1.0.3` (D-211) | W7 landed, its image deployed |
| K0 | Card purchases spike: local test app (D-176) | The test app landed `2ce317f`: `docs/spikes/k0/app.py` and its `README.md`, one command (`python3 docs/spikes/k0/app.py`, the standard library only, the RS256 JWT signed with `openssl`), a page on `http://localhost:8765/` that runs the real Enable Banking flow (bank list, BankID, paste-back of the redirect address, accounts, "Uppdatera" with PSU headers, "Bakgrundshämtning" without, balances, the purchases, an aggregate "Analys", "Koppla från"), key and state in the git-ignored `docs/spikes/k0/.local/`. Done 2026-10-24: run with the owner on his card; report `docs/spikes/K0-enable-banking.md` (aggregates only), its answers recorded in SPEC v0.36 (D-177 to D-180); the partner's card and the next-day checks were not run (owner's decision, D-177) and move to K1's owner steps | The flow worked end to end with the owner's card (owner's yes); the report answers every K0 item; `git status` shows nothing from `.local/`; no gate (not part of any gate) | Done 2026-10-24: Enable Banking account and Terms of Service, the restricted production application, the key in `.local/`, the owner's card linked with BankID, the test app run (PLAN-K0 §8) | SPEC v0.35 |
| K1 | Card purchases: server (D-158 to D-175) | Migration 0004 with its contract text and migration test, server schema 4; `src/cards/` (registry, Enable Banking adapter, the windowed fetch and de-duplication of §9.12 CP-7 and CP-8, poller, callback page); E-36 to E-44 in the server and `contracts/openapi.yaml` with examples; new error codes; the link clearing in §14.4.4 step 7; `contracts/test-vectors/match_card_purchase.json` and its Python reference in `tools/contracts-check.py`; config (§10.4), `infra/pi/compose.yaml`, `.env.example`, `infra/dev/**`, `infra/npm/kvitt.conf`, `infra/README.md` (step 4 block, card runbook); `web/src/api/schema.d.ts` regenerated | Server gate; `python3 tools/contracts-check.py`; the web gate (the regenerated types); an integration test suite against a local Enable Banking stub | Copy the key (already on the Mac from K0) to `~/kvitt/secrets/cards/`, set the application id in `.env`, paste the new NPM block, deploy; once a client with the card screens runs: the partner's card, the next-day fetch without BankID and pending→booked (D-177) (PLAN-K1 §8) | SPEC v0.36 (K0's answers) |
| K2 | Card purchases: Android (D-169, D-170, D-172, D-173) | `:feature:cards` (S-23 to S-27 and the sheet), Room version 2 with its migration test, the `CardRepository` UI contract in `:core:model`, `matchCardPurchase` in `:domain` passing its vectors, `:data` (DTOs, card sync, queued link changes, Custom Tab, deep link), fakes and scenarios, the fourth bottom destination, S-5's "Från kortköp", S-7's prefill and link, S-8's card line, the committed card artwork mapped (D-180), goldens; strings in `docs/ux/strings.csv` | Android gate | Connect the card on the phone with BankID; create an expense from a purchase; link one; disconnect and reconnect (PLAN-K2 §8) | SPEC v0.36; may start then on the spec and fakes; lands after K1 |
| K3 | Card purchases: web (D-174) | W-21 to W-23 in `web/src/features/cards/`, the sidebar entry, the S-7-style prefill in W-5 and the card line in W-6, the committed card artwork mapped (D-180), `matchCardPurchase` in TypeScript passing its vectors | Web gate | Connect the card from the laptop browser with BankID QR; create and link from W-21 (PLAN-K3 §8) | SPEC v0.36; lands after K1 and K2 (it takes the card strings K2 adds to `docs/ux/strings.csv`) |
| W7 | Server: web serving and analytics endpoints (D-132, D-133) | Landed `50a0d67` (2026-10-21, D-135 to D-138, SPEC v0.29; 299 server tests, contracts-check 136). `/app/` from `KVITT_WEB_DIR` with the SPA fallback, cache headers and the `/app/` CSP; the `/analytics/*` endpoints (D-135 to D-138, SPEC v0.29) in `contracts/openapi.yaml` and the server; the Node stage in `server/Dockerfile` with the repository root as build context (workflow, `infra/build-images.sh`, `infra/dev/**`) | Server gate; `python3 tools/contracts-check.py`; the web gate when `web/` exists and the contract changed (`web/src/api/schema.d.ts` regenerated) | Paste the v0.28 NPM block (`infra/README.md` step 4); deploy the new image (§10.10) | SPEC v0.28 |
| W7 | Server: web serving and analytics endpoints (D-132, D-133) | Landed `50a0d67` (2026-10-21, D-135 to D-138, SPEC v0.29; 299 server tests, contracts-check 136). `/app/` from `KVITT_WEB_DIR` with the SPA fallback, cache headers and the `/app/` CSP; the `/analytics/*` endpoints (D-135 to D-138, SPEC v0.29) in `contracts/openapi.yaml` and the server; the Node stage in `server/Dockerfile` with the repository root as build context (workflow, `infra/build-images.sh`, `infra/dev/**`) | Server gate; `python3 tools/contracts-check.py`; the web gate when `web/` exists and the contract changed (`web/src/api/schema.d.ts` regenerated) | Paste the v0.28 NPM block (`infra/README.md` step 4); deploy the new image (§10.10); deployed 2026-10-24 as `v1.0.3` (D-211) | SPEC v0.28 |
### 11.3 Notes per milestone
@@ -1509,7 +1509,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
* **Infra overhaul** (landed `2fac1ec` to `e70fe0d`, no decision): one Gradle lock helper `tools/gradle-lock.sh` for `tools/gradle-serial.sh`, `tools/clean-builds.sh` and `infra/build-images.sh`; the pre-commit hook runs the Android tests through `tools/gradle-serial.sh` with JDK 25 on `PATH`, `JAVA_HOME` untouched; `install.sh` keeps the deployed `KVITT_TAG` (moot since D-125: no `install.sh`); `--provenance=false` on buildx pushes and the workflow token through the environment (§10.3; reversed by D-120 point 7); `RCLONE_REMOTE` dropped from the development env (§10 intro). **Infra security** (landed `7fb86f5`, D-117): Caddy log redaction and response headers (§8.7, §10.5.2), the non-root Caddy container (§8.9, §10.2), the development stack's single secret (§10 intro), the digest record (§10.3, §10.10; dropped by D-125 for `docker image inspect`), `kvitt_cicd` with `write:package` only (§10.3), the restricted cert-sync key (§10.5.1). D-120 carries the log redaction and the headers over to NPM's Custom Nginx Configuration (§10.5.2) and removes the Caddy container, the digest of its image and the cert-sync key.
* **Overhaul outcomes** (2026-10-18). Server: landed through `b985ce9` (see the server overhaul above). Infra: landed through `e70fe0d` (see the infra overhaul above). **Android overhaul** (landed `e4e6e5d` to `c582363`, D-118, no prompt file, orchestrator brief): one screenshot and test harness in `:core:design`'s test fixtures and the convention plugin `kvitt.android.screenshots` (§6.2); the shared failure texts, limits and `savedSplitLabel` in `:core:design` (§6.2); dead code removed (`KvittMotion` down to the seesaw spring, `KvittIcon.Description`, unused dimens and shapes, catalogue and fake samples); the catalog entries `roborazzi-junit-rule`, `roborazzi-compose`, `androidx-hilt-navigation-compose` and `androidx-lifecycle-viewmodel-compose` removed; one `CopyCategoryKeywords` task in `build-logic` shared by `:data` and `:core:fakes` (D-055 holds); one `configureAndroidModule` for the application and library plugins; S-7's state types in `ExpenseEditorState.kt`. Gate at landing: 941 tests, no golden changed; release APK 10.46 MB. Pushed to `origin` at `c582363`.
* **M11** runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled.
* **W0 to W7** (D-134, §15.9): the web client programme. W0 and W7 run first in parallel (disjoint paths); W1 to W5 run in parallel once W0 has landed; W4's W-11 and W6 wait for W7. W0 owns `web/**` except the feature folders, registers every route with a placeholder page per feature folder (as M2a registered every route, D-004), and lists its module paths in its report; W1 to W5 build only inside their feature folders and take everything shared from W0's modules (a missing shared piece is a request to the orchestrator, as M7x's requests went to M6b). A second agent to land that changed `web/package.json` resolves `web/package-lock.json` with `npm install` on rebase (as `Cargo.lock`, §14.7). A plan that changes `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` (§15.3). Web agents do not use the phone or `tools/gradle-serial.sh`; a manual check runs against a throwaway server, never the owner's development or Pi data (§15.4). The orchestrator adds the web gate to `CLAUDE.md` "Build and verify" (D-131). W1 to W5 landed 2026-10-21 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; D-144 to D-154, D-150 unused) with the web gate green on `main` (59 test files, 532 tests at `1aecf51`); a web fix (`83f5293`) followed (598 tests). Pieces built twice or three times in feature folders and other loose ends are listed for a W0b plan in `docs/HANDOFF.md` "Web follow-ups".
* **W0 to W7** (D-134, §15.9): the web client programme. W0 and W7 run first in parallel (disjoint paths); W1 to W5 run in parallel once W0 has landed; W4's W-11 and W6 wait for W7. W0 owns `web/**` except the feature folders, registers every route with a placeholder page per feature folder (as M2a registered every route, D-004), and lists its module paths in its report; W1 to W5 build only inside their feature folders and take everything shared from W0's modules (a missing shared piece is a request to the orchestrator, as M7x's requests went to M6b). A second agent to land that changed `web/package.json` resolves `web/package-lock.json` with `npm install` on rebase (as `Cargo.lock`, §14.7). A plan that changes `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` (§15.3). Web agents do not use the phone or `tools/gradle-serial.sh`; a manual check runs against a throwaway server, never the owner's development or Pi data (§15.4). The orchestrator adds the web gate to `CLAUDE.md` "Build and verify" (D-131). W1 to W5 landed 2026-10-21 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; D-144 to D-154, D-150 unused) with the web gate green on `main` (59 test files, 532 tests at `1aecf51`); a web fix (`83f5293`) followed (598 tests). Pieces built twice or three times in feature folders and other loose ends are listed for a W0b plan in `docs/HANDOFF.md` "Web follow-ups". W0b landed `5305f37` (v0.34). All of W0 to W7 are landed and verified: `v1.0.3` (built on the Mac from `8fa9198`, D-211) runs on the Kvitt Pi and the owner checked the web client and the phone's Insights against it on 2026-10-24 (§11.4).
* **K0 to K3** (D-176, §11.1): the card-purchase programme. K0 runs first, on the Mac only, with the owner (a local test app for fast feedback before any Android work, owner's refinement 2026-10-24; the app landed `2ce317f`); K0 is done (run 2026-10-24, report `docs/spikes/K0-enable-banking.md`) and its answers are in SPEC v0.36 (D-177 to D-180), so K1 to K3 can start. The reports of K1 to K3 propose spec text and decisions that a docs agent records in SPEC v0.37. Then K1, K2 and K3 run in parallel in their own worktrees (disjoint paths, §14.7): K1 owns the contract (`contracts/**`, the migration text, the vectors) and lands first; K2 and K3 build against the spec and fakes, and rebase on K1 before their gates (K2's DTO tests and K3's generated types need K1's contract); K3 lands after K2, whose `docs/ux/strings.csv` rows it imports (`npm run i18n:import`). K2 uses the phone only when its brief names it; K1 and K3 never use the phone. No plan connects to the owner's real card except through the owner's own BankID step; no plan stores, prints or commits a real purchase.
@@ -1517,7 +1517,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/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. Observed 2026-10-24 (D-211), the web client's first use (FR-21) and W6: image `v1.0.3` on the Kvitt Pi, `server` and `backup` healthy; `https://kvitt.suorra.duckdns.org/app/` paired from the laptop browser with a device invite; the owner reports "All seems to work" for Home and an account, add, edit and delete of an expense, the CSV export, recording and deleting a payment, analytics with a category drill-down and Insights, every settings page, revoking the browser from the phone, and the Android Insights section on S-11 against this server.
* `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.
---
@@ -3449,6 +3449,8 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `web/` and served by `kvitt-server` under `/app/`. It is a second way into the same household: the same server, the same rules, the same data, read and written through the same API (§7, §14.3, §14.4). Nothing in the phone app changes because of it, except the Insights section of W6 (§15.8). This section is binding for the web plans W0 to W5 and for the web parts of W7; FR-21 holds the acceptance criteria.
**Status (2026-10-24, D-211).** W0 to W7 and W0b are landed; the web client is live at `https://kvitt.suorra.duckdns.org/app/` in image `v1.0.3`, paired from the owner's laptop browser and checked by the owner across its pages (§11.4).
### 15.1 Scope (D-129)
* **Parity.** Everything the Android app does, except what only makes sense on a phone: the phone pairing flow (S-1's server field, `kvitt://` links, S-3 as a separate screen, S-18 as a separate screen), push notifications (FR-13), offline use and the sync screen (FR-15, S-16), the in-app update (FR-16, S-17). In: Home balances, account detail, account editor, expense add, edit and delete, expense detail with the audit timeline, settle up and settlement detail, analytics (S-11, S-12 with the D-128 line chart), categories, recurring templates, devices, profile, invite partner and pair a device (admin), about; and since v0.35 the card purchases of FR-22 (D-174, K3: W-21 to W-23), with BankID by QR in the desktop browser.
@@ -3595,3 +3597,4 @@ Plans with disjoint paths (§14.7), one Opus agent each in its own worktree. W0
| 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). |
| 0.35 | 2026-10-24 | Card purchases through Enable Banking (owner's request and answers of 2026-10-24, D-158 to D-176) and plans K0 to K3. D-158: card purchases in scope, §2's card and bank non-goal struck through with a pointer, new US-18, US-19, FR-22 (AC-22.1 to AC-22.15), §9.12 (CP-1 to CP-14). D-159: Enable Banking in restricted production for the SJ Prio Mastercard (SEB Kort), G-7 allows it, PSD2 limits stated as facts, ToS as the owner's licence step. D-160: server-only credentials, one adapter `src/cards/enable_banking/`, provider registry, card catalogue E-36. D-161: only the card holder sees their purchases (owner's answer): own endpoints and cursor, not in `GET /sync`, audit, push or analytics, `not_found` for the other user, no card content in logs, session id protected by separation from the private key (§8.1, §8.4, §8.7, §10.9). D-162: connect flow with single-use attempts (E-37, E-38), the callback E-39 and its bilingual page, `kvitt://cards/connected`, polling. D-163: `/cards/callback` through the app gate and never logged (§10.5.2 block). D-164: history 7/30/90/180 days, default 30. D-165: background fetches at 06:45, 11:45, 16:45, 21:45, at most 4 per 24 hours; user-present "Uppdatera" with Enable Banking's PSU headers, once per 60 s; incremental fetch with a 10-day overlap; `card_bank_unavailable`. D-166: consent up to 180 days, notice 14 days before, renewal with BankID; `card_consent_expired`. D-167: migration 0004, server schema 4 (`card_sequence`, `card_connections`, `card_auth_attempts`, `card_purchases`, §14.1.3); disconnect rules (E-43). D-168: transaction mapping, pending→booked de-duplication, foreign currency, credits listed only under "Alla". D-169: "Skapa utgift" prefill, links (E-44), offline-capable link changes, unlink on expense delete (§14.4.4), `expense_already_linked`. D-170: suggested matches (same amount, ±3 days), `matchCardPurchase` with vectors `match_card_purchase.json` (§14.5.8). D-171: no bulk add, no card push (OQ-7, OQ-8). D-172: four bottom destinations, Översikt, Kortköp, Statistik, Inställningar (revises D-048); "Anslut kort" in Settings. D-173: `:feature:cards`, S-23 to S-27, Room version 2, `CardRepository` (§14.2.4), the owner's SJ Prio artwork with a drawn fallback. D-174: web W-21 to W-23 (K3). D-175: `KVITT_EB_*` configuration, `secrets/cards/`, two optional `.env` keys; `cards_unavailable`. D-176: plans K0 (the local test app of `2ce317f`, owner's refinement) to K3; K0's `TODO(spec)` answers go into v0.36 before K1. Endpoints E-36 to E-44; strings for the card screens and the callback page in §13.3; ownership rows K0 to K3 (§14.7); contract files 0004 and the new vector file (§14.8). |
| 0.36 | 2026-10-24 | K0's results (the spike run on the owner's SJ Prio Mastercard on 2026-10-24 with `docs/spikes/k0/app.py`, report `docs/spikes/K0-enable-banking.md`), D-177 to D-180; no contract file changes, server schema 4 and Room 2 as specified in v0.35. D-177: K0's outcome; every K0 `TODO(spec)` of §9.12 answered: ASPSP `SJ Prio Mastercard`, `SE` (CP-1); one `CARD` account per session (CP-2); at least 150 days of history right after BankID, so 7/30/90/180 stay (CP-3, §9.6); a background fetch without PSU headers accepted (CP-4); `required_psu_headers` [`psu-ip-address`], compared case-insensitively (CP-5); `maximum_consent_validity` 180 days, `valid_until` 2027-04-22 (CP-6); foreign purchases arrive in SEK (CP-7); credits were card-bill payments (CP-8); and the owner's assumptions of 2026-10-24 for what was not measured: the partner's card links to the owner's application (CP-13, the D-175 fallback stays), a pending transaction keeps its `entry_reference` when booked (CP-8, the amount-and-date rule becomes the secondary rule), the session is reused on later days without BankID (CP-6), all three verified by K1's owner steps. D-178: the card returns at most 100 transactions per response and never a `continuation_key`, so history is fetched in windows of at most 30 days, newest first, a window at 100 rows split in half down to one day, de-duplicated across windows by `provider_ref`; `strategy=longest` dropped; `WRONG_TRANSACTIONS_PERIOD` ends the walk back; only a complete fetch deletes vanished pending purchases (CP-7, CP-8); `card_fetch` counts requests, splits, truncated windows, `booked_by_ref` and `booked_by_match` (§8.7). D-179: transaction mapping from K0's field survey: merchant = `remittance_information`, else `creditor.name`, else "Kortköp"; `purchase_date` = `booking_date`, else `value_date`, else `transaction_date`; `bank_transaction_code` unused, so a debit `FEE` is a purchase to handle; the holder's name, the masked card number (`CPAN`) and the merchant's address are never stored, logged or shown (CP-7). D-180: the owner's SJ Prio Mastercard artwork cropped to the card (1313 × 829) and committed for Android and the web, mapped from the first commit of K2 and K3, the goldens use it, the owner's artwork steps removed (revises D-173, D-174). The K plans' proposed spec text goes into v0.37 (§11.1, §11.3). |
| 0.37 | 2026-10-24 | D-211: image builds on the Mac with Podman (`infra/build-images.sh v1.0.N`) are the normal path, the Gitea workflow on the Pi 3 runner the fallback (revises D-156 point 5); tag rule (the next `v1.0.N` after the highest tag on `origin`, pushed as a git tag too, `v1.0.3` = `8fa9198`); the registry from the Mac is `gitea.lan` on port 80, the Pi's `.env` keeps `gitea.lan:3001`; Podman's insecure-registry files on the Mac and in the Podman machine; the script fixes `f15fa23` and `8fa9198` (§10.3, §10.10). `v1.0.3` deployed on the Kvitt Pi and the web client and S-11's Insights verified by the owner (§11.2 W1 to W7, §11.3, §11.4, §15). No contract file changes. |