SPEC v0.38: K1's report recorded (D-181 to D-188), K2 v0.39 and K3 v0.40

K1 landed 671c00d (card purchases on the server, schema 4, E-36 to E-44). Decisions: D-181
history counts today (6 windows for 180 days, revises CP-3 and CP-7); D-182 the server's CS-4
first token; D-183 Enable Banking errors to CP-14; D-184 callback (expired state stored as
expired, revises CP-2; atomic claim; session without accounts revoked); D-185 fetch details;
D-186 PSU headers; D-187 E-41, E-43, E-44 details, CardAttempt.failure a plain string; D-188
placement and the secrets/cards compose mount. D-189, D-190 unused.

SPEC: §9.12 CP-2 to CP-5, CP-7, CP-8, CP-14; §10.2; §11.1, §11.2 K1 row with the landing
gates, §11.3; §14.1.3; §14.3.3; §14.3.4 (E-1 schema_version 4, E-37, E-39, E-41, E-43, E-44);
§14.3.5; §14.7; change log. PLAN-K2, PLAN-K3 and prompts/README.md name v0.39 and v0.40.
HANDOFF: K1 landed, K2 and K3 running, the Android gate red until K2, K1 owner steps 1 to 4
pending, next free D-212 and v0.39.

Pre-commit hook bypassed (--no-verify): docs only; its Android fast tests fail only in
:domain VectorFilesTest, red on main by design until K2 lands (as for 671c00d).

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 16:50:04 +02:00
parent 671c00d0d8
commit 07df30d04d
6 changed files with 53 additions and 40 deletions
+9 -8
View File
@@ -3,8 +3,8 @@
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
## 1. Where things stand
- State as of 2026-10-24. `main` at `8b620c8` (the K0 report, SPEC v0.36), after `8fa9198` (`infra/build-images.sh` Podman detection), `f15fa23` (`infra/build-images.sh` passes `KVITT_VERSION_CODE`), `4339d01` (the owner's SJ Prio card artwork), `b5114ad` (SPEC v0.35, card purchases), `2ce317f` (the K0 card test app), `5d948f9` (SPEC v0.34) and `5305f37` (W0b web shared follow-ups); before them `07c36d2` (L-10, D-156) and `593c727` (SPEC v0.33, D-156, D-157). This pass: SPEC v0.37, D-211. **Deployed and verified 2026-10-24** (owner, D-211): image `v1.0.3` (git tag `v1.0.3` = `8fa9198`, built on the Mac) runs on the Kvitt Pi, `server` and `backup` healthy; the web client is live at `https://kvitt.suorra.duckdns.org/app/`, paired from the laptop browser with a device invite, and the owner reports "All seems to work" for Home and an account, add, edit and delete an expense, CSV export, record and delete a payment, analytics with category drill-down and Insights, the settings pages, revoking the browser from the phone, and the Android Insights section on S-11 against the new server. W0 to W7 are landed and verified (SPEC §11.2, §15). Release 140 (`kvitt-140`, deployed 2026-10-21) runs on both phones. Gates on `main`: server 300 tests, web 609 tests, contracts-check 136.
- **Card purchases programme** (owner request and answers 2026-10-24, SPEC v0.35, D-158 to D-176, FR-22, §9.12): each user connects their own SJ Prio Mastercard (SEB Kort) through Enable Banking's restricted production mode with BankID; the server alone holds the key and sessions, fetches purchases up to 4 times a day in the background plus "Uppdatera", and only the card holder sees them (not in `GET /sync`, audit, push or analytics); a fourth bottom destination "Kortköp" (Översikt, Kortköp, Statistik, Inställningar, revising D-048), "Anslut kort" in Settings with the SJ Prio artwork, "Skapa utgift" prefilled into S-7, one-tap links to matching expenses (same amount, ±3 days), "Från kortköp" on account detail; server schema 4 (migration 0004), Room 2, endpoints E-36 to E-44, screens S-23 to S-27, web W-21 to W-23. Plans `PLAN-K0.md` to `PLAN-K3.md`, prompts `prompts/K0.md` to `prompts/K3.md`, written by `agent-a3b7abf183f883261`, landed `b5114ad`. **K0 done** (2026-10-24): the test app (`2ce317f`) ran with the owner on his card; report [`docs/spikes/K0-enable-banking.md`](spikes/K0-enable-banking.md); its answers are SPEC v0.36 (D-177 to D-180). Main findings: 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, split in half at 100 rows (D-178); merchant text from `remittance_information`, the date `booking_date` else `value_date`, everything in SEK, `entry_reference` on pending rows too (D-179); consent 180 days; background fetch accepted; at least 150 days of history right after BankID. **Owner's assumptions** (2026-10-24, "let's just assume it all works now given your test", D-177), verified by K1's owner steps (PLAN-K1 §8 steps 5 to 7): the partner's card links to the owner's application (fallback: her own application, D-175), a pending purchase keeps its `entry_reference` when booked (fallback: CP-8's amount-and-date rule), the session is reused the next day without BankID. The Enable Banking application exists (restricted production, redirect URL `https://kvitt.suorra.duckdns.org/cards/callback`, the owner's card linked); its key file lives only in the main checkout's git-ignored `docs/spikes/k0/.local/<application id>.pem` and reaches the Pi through K1's owner steps (`~/kvitt/secrets/cards/enable-banking.pem`, D-175), never downloaded again. The artwork is committed for Android and the web, cropped to the card (D-180). **Next: K1** (server, contract) with **K2** (Android) and **K3** (web) starting in parallel on the spec and fakes; K1 lands first, K2 after it, K3 after K2; their proposed spec text goes into v0.37.
- State as of 2026-10-24. `main` at `671c00d` (**K1 landed**: card purchases on the server, schema 4, E-36 to E-44), after `ae73f58` (SPEC v0.37, D-211), `8b620c8` (the K0 report, SPEC v0.36), `8fa9198` (`infra/build-images.sh` Podman detection), `f15fa23` (`infra/build-images.sh` passes `KVITT_VERSION_CODE`), `4339d01` (the owner's SJ Prio card artwork), `b5114ad` (SPEC v0.35, card purchases), `2ce317f` (the K0 card test app), `5d948f9` (SPEC v0.34) and `5305f37` (W0b web shared follow-ups); before them `07c36d2` (L-10, D-156) and `593c727` (SPEC v0.33, D-156, D-157). This pass (`agent-docs-k1`): SPEC v0.38, K1's report as D-181 to D-188; `main` with this pass is pushed by the orchestrator after it lands. **Deployed and verified 2026-10-24** (owner, D-211): image `v1.0.3` (git tag `v1.0.3` = `8fa9198`, built on the Mac) runs on the Kvitt Pi, `server` and `backup` healthy; the web client is live at `https://kvitt.suorra.duckdns.org/app/`, paired from the laptop browser with a device invite, and the owner reports "All seems to work" for Home and an account, add, edit and delete an expense, CSV export, record and delete a payment, analytics with category drill-down and Insights, the settings pages, revoking the browser from the phone, and the Android Insights section on S-11 against the new server. W0 to W7 are landed and verified (SPEC §11.2, §15). Release 140 (`kvitt-140`, deployed 2026-10-21) runs on both phones. Gates on `main` at `671c00d`: server 356 tests, web 609 tests (`match_card_purchase` listed in `NOT_PORTED` until K3), contracts-check 157 (17 `match_card_purchase` cases). **The Android gate on `main` is red by design until K2 lands**: `:domain` `VectorFilesTest` and `:data` `ExampleRoundTripTest` pin the contract's vector and example sets, which K1 extended (contract-first landing; K1 was committed with the pre-commit hook bypassed for that reason). Until K2 lands, a commit on `main` that runs the hook needs the same bypass, decided by the orchestrator.
- **Card purchases programme** (owner request and answers 2026-10-24, SPEC v0.35, D-158 to D-176, FR-22, §9.12): each user connects their own SJ Prio Mastercard (SEB Kort) through Enable Banking's restricted production mode with BankID; the server alone holds the key and sessions, fetches purchases up to 4 times a day in the background plus "Uppdatera", and only the card holder sees them (not in `GET /sync`, audit, push or analytics); a fourth bottom destination "Kortköp" (Översikt, Kortköp, Statistik, Inställningar, revising D-048), "Anslut kort" in Settings with the SJ Prio artwork, "Skapa utgift" prefilled into S-7, one-tap links to matching expenses (same amount, ±3 days), "Från kortköp" on account detail; server schema 4 (migration 0004), Room 2, endpoints E-36 to E-44, screens S-23 to S-27, web W-21 to W-23. Plans `PLAN-K0.md` to `PLAN-K3.md`, prompts `prompts/K0.md` to `prompts/K3.md`, written by `agent-a3b7abf183f883261`, landed `b5114ad`. **K0 done** (2026-10-24): the test app (`2ce317f`) ran with the owner on his card; report [`docs/spikes/K0-enable-banking.md`](spikes/K0-enable-banking.md); its answers are SPEC v0.36 (D-177 to D-180). Main findings: 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, split in half at 100 rows (D-178); merchant text from `remittance_information`, the date `booking_date` else `value_date`, everything in SEK, `entry_reference` on pending rows too (D-179); consent 180 days; background fetch accepted; at least 150 days of history right after BankID. **Owner's assumptions** (2026-10-24, "let's just assume it all works now given your test", D-177), verified by K1's owner steps (PLAN-K1 §8 steps 5 to 7): the partner's card links to the owner's application (fallback: her own application, D-175), a pending purchase keeps its `entry_reference` when booked (fallback: CP-8's amount-and-date rule), the session is reused the next day without BankID. The Enable Banking application exists (restricted production, redirect URL `https://kvitt.suorra.duckdns.org/cards/callback`, the owner's card linked); its key file lives only in the main checkout's git-ignored `docs/spikes/k0/.local/<application id>.pem` and reaches the Pi through K1's owner steps (`~/kvitt/secrets/cards/enable-banking.pem`, D-175), never downloaded again. The artwork is committed for Android and the web, cropped to the card (D-180). **K1 landed** `671c00d` (2026-10-24): migration 0004 (byte-identical contract and server copies), `src/cards/` with the registry, the Enable Banking adapter, the windowed fetch, the poller and the callback page, E-36 to E-44 with four new error codes and a 502 response, examples, `match_card_purchase.json` (17 cases) with its Python reference, `tests/cards_api/` (34 tests against a local Enable Banking stub; the real API is never called), the Pi compose mount of `secrets/cards/` and the NPM `/cards/callback` block; its report is SPEC v0.38 (D-181 to D-188). **Running: K2** (Android, its text into v0.39) and **K3** (web, v0.40), decisions from D-191 to D-210 in disjoint ranges; K2 lands next, K3 after K2. The key is staged at `infra/secrets/cards/enable-banking.pem` in the main checkout (git-ignored); the application id is known to the orchestrator (not written here, CP-13).
- **Verified 2026-10-24** (owner, D-156): Samsung Auto Blocker was the cause of the silent update failure; turning it off let the update install, and the owner turned it back on, so every in-app update on the Samsung phones needs it off (the D-155 blocked text names it; SPEC AC-16.3, `infra/README.md`). The editor keyboard fix (S-19, S-15, S-6, `05ec3f3`) works on the device. The first backup on the Kvitt Pi ran and a snapshot pair is in the Drive folder (AC-18.1). The runner Pi's disk reached 11 of 15 GB during image builds; the owner runs `docker builder prune -af` between builds (SPEC §10.3).
- **Kvitt host name** (owner decision 2026-10-19): `kvitt.suorra.duckdns.org`, a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`; NPM issues its Let's Encrypt certificate; `KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org`, so `KVITT_PUBLIC_URL=https://kvitt.suorra.duckdns.org` (port 443, D-124). It is in `infra/pi/.env.example` and `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`, git-ignored). SPEC §10.1 and §10.5.1 point here.
- **NPM proxy host** for `kvitt.suorra.duckdns.org` is Online with a Let's Encrypt certificate (owner, 2026-10-19). The router needs no new rule: external TCP 443 already forwards to the Home Assistant Pi (owner confirmed, D-124).
@@ -67,7 +67,8 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 answered 2026-10-24: `not_found` for the other user's device (D-156), fixed in `07c36d2`. Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 is fixed (`725901c`).
- Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18.
- In flight:
- This docs pass (`agent-docs-v35`): SPEC v0.37, `docs/decisions.md` D-211 (Mac image builds, `v1.0.3` deployed, the web client verified), `infra/README.md`, this file.
- This docs pass (`agent-docs-k1`): SPEC v0.38, `docs/decisions.md` D-181 to D-188 (D-189, D-190 unused), `PLAN-K2.md`, `PLAN-K3.md`, `prompts/README.md` (v0.39 and v0.40), this file.
- K2 (Android) and K3 (web), Opus agents in their own worktrees.
- Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2).
- Compatibility: an APK from M12e on cannot pair or list devices against a pre-M12e server, so the server is rebuilt and redeployed before a new APK is installed (SPEC §14.8). The dev server already runs schema 3.
- Remote: `origin` on Gitea, SSH key works; pushed at `c13be3b` (2026-10-24). The Gitea secret `kvitt_cicd` (a user-level secret of `jacobh2`, renamed by the owner 2026-10-19) works; the Drive token is `infra/secrets/rclone.conf` (`[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted).
@@ -78,7 +79,7 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB.
## 2. Open work (one line each, with who owns it: agent or owner)
- K1, K2, K3 (Opus agents, once SPEC v0.36 is on `main`; decisions from D-181 in disjoint ranges, spec version v0.37 for their proposed text): K1 lands first, K2 after K1, K3 after K2. After deploy, K1's owner steps 5 to 7 verify K0's three assumptions (D-177).
- K1 landed `671c00d` (recorded in v0.38, D-181 to D-188). K2 and K3 running (agents; decisions from D-191 to D-210 in disjoint ranges, spec text into v0.39 for K2 and v0.40 for K3): K2 lands next and turns the Android gate green again, K3 after K2. Next: build the image with K1 (`infra/build-images.sh v1.0.N`, orchestrator), then K1's owner steps 1 to 4 (§3). After a client with the card screens is deployed, K1's owner steps 5 to 7 verify K0's three assumptions (D-177).
- K0 test app session in `docs/spikes/k0/.local/state.json` (owner): keep it or press "9 Koppla från" in the test app (PLAN-K0 §6 step 7); K1 does not use it. The key file stays in `.local/` either way.
- Card design-spec sections S-23 to S-27 for `docs/ux/design-spec.md` (docs agent, from K2's report).
- Closed by `f15fa23` and `8fa9198`: `infra/build-images.sh` passes `--build-arg KVITT_VERSION_CODE` to the server build as the workflow does (v0.34), and detects Podman without a pipe.
@@ -112,7 +113,7 @@ Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers health
Standing note for every in-app update on the Samsung phones: turn Auto Blocker off before tapping "Uppdatera", back on afterwards (D-156). Images are built on the Mac (D-211); only if the fallback workflow runs: `docker builder prune -af` on the runner Pi between two of its builds.
1. Gate (step 4a items 4 and 5), both phones run the keyed APK (release 140): NPM, Hosts, Proxy Hosts, Kvitt row, Edit, Advanced: paste the step 4 block with the key in place of `KVITT_APP_KEY`, Save (row Online, yes/no). Acceptance test: on home Wi-Fi Chrome opens `https://kvitt.suorra.duckdns.org/i/TEST` and the invite page shows (yes/no); with Wi-Fi off the same URL gives a connection error (yes/no); with Wi-Fi off Kvitt's About screen shows the server version (yes/no).
2. Web client gate lines (D-130, SPEC v0.28): when the NPM block is pasted (item 1, or again if the D-126 block is already in NPM), paste the current `infra/npm/kvitt.conf` content (step 4), which also lets the web client through and sets its Content-Security-Policy; Save (row Online, yes/no). The image with the web client is deployed (`v1.0.3`, D-211); acceptance test with Wi-Fi off on the phone: `https://kvitt.suorra.duckdns.org/app/` shows the Kvitt login page (yes/no) and `https://kvitt.suorra.duckdns.org/health` still gives a connection error (yes/no) (step 4a item 5 d and e).
3. Card purchases: K0's owner steps are done (2026-10-24: Enable Banking account and Terms of Service, the application, the key in `docs/spikes/k0/.local/`, the owner's card linked, the test app run); the artwork is committed (D-180). The partner's card and the next-day check moved to K1's owner steps (PLAN-K1 §8 steps 5 to 7, after K1 and a client with the card screens are deployed); K1's steps 2 to 4 (key to the Pi, `.env`, NPM block, deploy) come first.
3. Card purchases: K0's owner steps are done (2026-10-24: Enable Banking account and Terms of Service, the application, the key in `docs/spikes/k0/.local/`, the owner's card linked, the test app run); the artwork is committed (D-180). **K1's owner steps 1 to 4 are pending** (PLAN-K1 §8), relayed by the orchestrator in chat after the image with K1 is built: step 1 is done by the orchestrator (the key staged at `infra/secrets/cards/enable-banking.pem` in the main checkout, git-ignored, mode 0600); step 2 on the Pi in `~/kvitt`: `mkdir -p secrets/cards && chmod 700 secrets/cards` before anything else (the new `compose.yaml` refuses to start without that folder, even with no card configured, D-188), `scp` the key, `chmod 600 secrets/cards/*`, copy the new `compose.yaml`, set `KVITT_EB_APP_ID=` in `.env` (the orchestrator gives the id in chat) and leave `KVITT_EB_MEMBER_APP_ID=` empty; step 3 paste the current `infra/npm/kvitt.conf` into NPM (row Online, yes/no); step 4 backup, `KVITT_TAG`, `docker compose pull && docker compose up -d` (`server` healthy, `/health` shows `"schema_version":4`, yes/no). The partner's card, the next-day check and pending to booked (PLAN-K1 §8 steps 5 to 7) follow once a client with the card screens is deployed.
## 4. Owner questions (asked, unanswered)
- OQ-7 (SPEC §12): a notification for new card purchases? Default: none in this version (D-171).
@@ -125,9 +126,9 @@ Standing note for every in-app update on the Samsung phones: turn Auto Blocker o
- Nothing.
## 6. Numbering
- Decisions: D-001 to D-180 and D-211 used, except D-008, D-009 and D-150 (never used); D-158 to D-176 are the card purchases (v0.35), D-177 to D-180 K0's results and the artwork (v0.36), D-211 the Mac image builds and the verified `v1.0.3` deploy (v0.37). **D-181 to D-210 are reserved for K1 to K3**; next free after them: **D-212**.
- Spec version: v0.37 written (D-211: Mac image builds, `v1.0.3` deployed, the web client verified). Next **v0.38**. The K plans, `prompts/README.md`, §2's K item and SPEC §11.1 and §11.3 still name v0.37 for the K1 to K3 text (orchestrator: give them v0.38 in the briefs or have the references renamed).
- Server schema version **3** in code (migration 0003); **4** specified (migration 0004, §14.1.3) for K1. Room schema version **1** in code; **2** specified (§14.2.3) for K2.
- Decisions: D-001 to D-190 and D-211 used, except D-008, D-009, D-150, D-189 and D-190 (never used); D-158 to D-176 are the card purchases (v0.35), D-177 to D-180 K0's results and the artwork (v0.36), D-211 the Mac image builds and the verified `v1.0.3` deploy (v0.37), D-181 to D-188 K1's report (v0.38). **D-191 to D-210 are reserved for K2 and K3**; next free after them: **D-212**.
- Spec version: v0.38 written (K1's report, D-181 to D-188). Next **v0.39** (K2's text), then **v0.40** (K3's); `PLAN-K2.md`, `PLAN-K3.md`, `prompts/README.md` and SPEC §11.1 and §11.3 name them.
- Server schema version **4** in code (migration 0004, K1 `671c00d`); the Kvitt Pi and the dev stack still run schema 3 until their next deploy. Room schema version **1** in code; **2** specified (§14.2.3) for K2.
- New ids in v0.35: FR-22 (AC-22.1 to AC-22.15), US-18, US-19, CP-1 to CP-14 (§9.12), S-23 to S-27, W-21 to W-23, E-36 to E-44, OQ-7, OQ-8; plans K0 to K3.
- Sync protocol **1**, push payload **1**.
+12 -2
View File
@@ -166,9 +166,9 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| D-159 | 2026-10-24 | Provider: Enable Banking (enablebanking.com) in its restricted production mode (free, for evaluation and individual non-commercial use, data only from accounts linked to the application in its control panel with the bank's own BankID authorisation). The SJ Prio Mastercard is issued by SEB Kort Bank AB and reachable through PSD2 AIS; Enable Banking's changelog (August 2024) lists "SJ Prio Mastercard (SE)" (Tink and GoCardless list it too; GoCardless Bank Account Data is closed to new sign-ups). G-7 now allows Enable Banking as an external service, for card purchases only. PSD2 facts the rules rest on: at most 4 accesses per 24 hours without the user present (RTS Art. 36(5)(b)); renewed BankID at most every 180 days (2023 RTS amendment; the bank's `maximum_consent_validity` may be lower); history beyond about 90 days usually only right after authentication. The owner accepts Enable Banking's Terms of Service before an application is created (the "Licences first" step of `CLAUDE.md`, recorded as an owner step in PLAN-K0). What only the real card can show is a `TODO(spec)` resolved by K0 with a stated default. | Restricted production fits a two-person private app at no cost and needs no licence as a provider; the alternatives are closed to new users or paid. | SPEC §1 G-7, §9.12 (facts), CP-13; PLAN-K0 |
| D-160 | 2026-10-24 | Architecture: the Enable Banking application's private key, its JWTs and the session ids live on the server only; the phones and the browser never talk to Enable Banking. Vendor code sits in one adapter, `kvitt-server` `src/cards/enable_banking/`, behind a `CardSource` trait, mapping the vendor's JSON into Kvitt's own records; nothing else names the vendor. A provider registry (`src/cards/providers.rs`) holds the card catalogue with open-string ids, served by E-36 `GET /cards/providers`: id `sj_prio_mastercard`, name "SJ Prio Mastercard", issuer "SEB Kort", ASPSP name and country (K0 confirms; answered by D-177: `SJ Prio Mastercard`, `SE`), image key `sj_prio_mastercard`, `available`. The clients map the image key to their bundled artwork. No new crate: reqwest with rustls and jsonwebtoken are already in the stack. | `CLAUDE.md`'s vendor-in-one-adapter and open-string-id rules; a key on the phones would put a credential for both users' cards on two lost-able devices. | SPEC §6.1, §6.3, §7, §9.12 CP-1, §14.3.4 E-36 |
| D-161 | 2026-10-24 | Privacy: only the card holder sees their card purchases. Owner's answer: "only the card holder". Card connections and purchases are read (E-40, own cursor `card_version`) and written (E-44) only by the holder's devices; any other caller gets `not_found` for their ids (cf. D-156). Not in `GET /sync`, the audit log, any push, S-11, S-12 or the D-133 endpoints; the partner's phone stores none. An expense made from a purchase is an ordinary household expense. Server logs carry no merchant text, amount, purchase date, session id, account uid, `code` or `state` (one aggregate `card_fetch` line per fetch). Backups include the card tables (the whole database is snapshot). The session id is stored in plain text and protected by separation: it is usable only with the application's private key, which lives in `secrets/cards/`, never in the database or a backup; it is never logged and never sent to a client. | The owner's answer; the separation gives at-rest protection without a key-management scheme the Pi does not have, and keeps a leaked backup (unencrypted, accepted risk M-1) from exposing the cards. | SPEC §8.1, §8.4, §8.7, §9.12 CP-11, §10.9, §14.4.1, §14.6, AC-22.3, AC-22.14 |
| D-162 | 2026-10-24 | Connect flow: E-37 `POST /cards/auth` creates a single-use attempt (32-byte `state`, stored as its SHA-256 hash, bound to user and device, 15-minute expiry, a newer attempt expires an older pending one) and returns Enable Banking's URL; the app opens it in a Custom Tab, the web in the same tab; BankID app-to-app on the phone, QR on a computer; the bank redirects to the new path `https://<KVITT_PUBLIC_HOST>/cards/callback` (E-39), where the server exchanges the code (`POST /sessions`), picks the card account (K0 confirms the rule when a session has several; D-177: the SJ Prio session holds one `CARD` account, the rule for several stays as written), stores the connection, starts the first fetch in the background and shows a small bilingual page "Kortet är anslutet" with the link "Tillbaka till Kvitt": `kvitt://cards/connected?attempt=<id>` (app) or `/app/cards?attempt=<id>` (web). The page never jumps by itself; the client learns the result by polling E-38 every 2 seconds while its connect screen is open and from the return link. Renewal is the same flow on the existing connection. | A custom-scheme link from a user's tap opens the app without App Links verification, which would need `/.well-known/assetlinks.json` served through the app gate and `autoVerify` (the app uses `autoVerify="false"`, AC-2.6); browsers may block a scripted jump to `kvitt://`, so polling makes the result independent of whether the user taps the link or just closes the tab. 15 minutes covers a BankID signing with margin. | SPEC §6.1, §7, §8.6, §9.12 CP-2, §13.3 S-25 and "Card callback page", §14.1.3, §14.3.4 E-37 to E-39 |
| D-162 | 2026-10-24 | Connect flow: E-37 `POST /cards/auth` creates a single-use attempt (32-byte `state`, stored as its SHA-256 hash, bound to user and device, 15-minute expiry, a newer attempt expires an older pending one) and returns Enable Banking's URL; the app opens it in a Custom Tab, the web in the same tab; BankID app-to-app on the phone, QR on a computer; the bank redirects to the new path `https://<KVITT_PUBLIC_HOST>/cards/callback` (E-39), where the server exchanges the code (`POST /sessions`), picks the card account (K0 confirms the rule when a session has several; D-177: the SJ Prio session holds one `CARD` account, the rule for several stays as written), stores the connection, starts the first fetch in the background and shows a small bilingual page "Kortet är anslutet" with the link "Tillbaka till Kvitt": `kvitt://cards/connected?attempt=<id>` (app) or `/app/cards?attempt=<id>` (web). The page never jumps by itself; the client learns the result by polling E-38 every 2 seconds while its connect screen is open and from the return link. Renewal is the same flow on the existing connection. (Callback details refined by D-184: an expired `state` is stored as `expired`, the attempt is claimed atomically.) | A custom-scheme link from a user's tap opens the app without App Links verification, which would need `/.well-known/assetlinks.json` served through the app gate and `autoVerify` (the app uses `autoVerify="false"`, AC-2.6); browsers may block a scripted jump to `kvitt://`, so polling makes the result independent of whether the user taps the link or just closes the tab. 15 minutes covers a BankID signing with margin. | SPEC §6.1, §7, §8.6, §9.12 CP-2, §13.3 S-25 and "Card callback page", §14.1.3, §14.3.4 E-37 to E-39 |
| D-163 | 2026-10-24 | `/cards/callback` passes NPM's app gate from outside (the bank's redirect comes from the user's browser, a Custom Tab on mobile data included, without the app key) and is never logged: a gate line `if ($uri = "/cards/callback")` and `location = /cards/callback` with `access_log off` and `error_log /dev/null crit`, as `/i/` (§10.5.2); the server logs it under its route template only. It is an unauthenticated endpoint for the rate limits (60 per minute per IP). K1 updates `infra/npm/kvitt.conf`, `infra/README.md` step 4 and repeats the nginx check. | The query holds a one-time authorisation code and the attempt's `state`; the gate must let the redirect through or the connection can only be made on the home Wi-Fi. | SPEC §8.6, §8.7, §10.5.2, AC-22.14; `infra/npm/kvitt.conf`, `infra/README.md` (K1) |
| D-164 | 2026-10-24 | History: the connect screen offers 7, 30, 90 and 180 days, default 30; the first fetch after an authorisation goes back that far. If K0 shows the bank gives less than 180 days, the choices are trimmed to what was reached (`TODO(spec)` K0; answered by D-177: at least 150 days right after BankID, so 180 stays). The setting can be shortened later (E-41, affects later renewals only, stored purchases stay); a longer one goes through "Förnya med BankID", and the UI says "En längre period kräver BankID igen". | The owner: "probably we do not have to fetch the entire history"; 30 days covers the purchases not yet entered; deeper history is available only right after BankID, so lengthening needs a renewal. | SPEC §9.6, §9.12 CP-3, §13.3 S-25, S-26, §14.3.4 E-41 |
| D-164 | 2026-10-24 | History: the connect screen offers 7, 30, 90 and 180 days, default 30; the first fetch after an authorisation goes back that far (D-181: today counts as one of the days). If K0 shows the bank gives less than 180 days, the choices are trimmed to what was reached (`TODO(spec)` K0; answered by D-177: at least 150 days right after BankID, so 180 stays). The setting can be shortened later (E-41, affects later renewals only, stored purchases stay); a longer one goes through "Förnya med BankID", and the UI says "En längre period kräver BankID igen". | The owner: "probably we do not have to fetch the entire history"; 30 days covers the purchases not yet entered; deeper history is available only right after BankID, so lengthening needs a renewal. | SPEC §9.6, §9.12 CP-3, §13.3 S-25, S-26, §14.3.4 E-41 |
| D-165 | 2026-10-24 | Refresh: the server fetches each active connection in the background at 06:45, 11:45, 16:45 and 21:45 Europe/Stockholm, never more than 4 starts per connection per 24 hours, skipping a slot after a successful fetch less than 2 hours old; "Uppdatera" and pull to refresh call E-42, a user-present fetch carrying the user's IP and user agent in Enable Banking's PSU headers, at most once per connection per 60 seconds (a later tap within 60 seconds answers the current state), waiting up to 25 seconds. Each fetch is incremental from the latest stored booking date minus 10 days (late bookings and pending→booked), ~~retried once with `strategy=longest` on `WRONG_TRANSACTIONS_PERIOD`~~ (revised by D-178: fetched in windows of at most 30 days, `strategy` never sent, `WRONG_TRANSACTIONS_PERIOD` ends the walk back). PSU headers per Enable Banking's documentation: "either all required PSU headers (in case of online data fetching) or none", the required set from the ASPSP's `required_psu_headers`; Kvitt sends `Psu-Ip-Address` and `Psu-User-Agent` always and the other required ones from the app's request, never `Psu-Geo-Location`. Enable Banking's FAQ: "many ASPSPs have the limit of 4 times a day for data fetches when PSU is not online". Phones pull card rows on every household sync trigger, so background fetches reach the phone without a tap. Errors are kept on the connection (`bank_unavailable`, `rate_limited`, `consent_expired`, `failed`) and shown as "Kunde inte hämta köp. Senast uppdaterad <time>". New error code `card_bank_unavailable` (502). | PSD2's 4-per-24-hours limit for background access; slots spread over the day and away from the 04:00 and 06:00 jobs; the user-present path is not limited by PSD2 but banks throttle; 10 days covers card bookings, which settle within a few business days. "Auto-update" is therefore possible, as the owner hoped. | SPEC §8.6, §9.12 CP-4, CP-5, CP-7, CP-14, §13.3 S-23, §14.3.2, §14.3.4 E-42 |
| D-166 | 2026-10-24 | Consent: `access.valid_until` asks for the smaller of 180 days and the bank's `maximum_consent_validity` (seconds, from `GET /aspsps`; K0 reads the SJ Prio value; D-177: 15552000 s, 180 days), less 5 minutes; the stored `valid_until` is the session's. From 14 days before it, S-23 and S-26 show "Kortanslutningen upphör <date>" with "Förnya med BankID"; after it (or when Enable Banking reports the session expired or revoked) the status is `expired`, fetching stops, stored purchases keep working, and renewal keeps the connection and every purchase. New error code `card_consent_expired` (409). | BankID is needed only to connect and then every ≤180 days; two weeks' notice spans two weekends, enough to renew at a convenient moment. | SPEC §9.12 CP-6, AC-22.11, §13.3 S-23, S-26, §14.3.2 |
| D-167 | 2026-10-24 | Server data: migration 0004, server schema 4 (§14.1.3): `card_sequence` (own counter `card_version`, so `server_version` and `GET /sync` are untouched), `card_connections` (one per user and provider; session id, account uid, `valid_until`, status `active`/`expired`/`disconnected`, `history_days`, last fetch and error, the last four background-fetch start times), `card_auth_attempts` (pruned after 7 days by the 04:00 job) and `card_purchases` (provider reference, status, direction, dates, `amount_ore` or the original amount and currency, merchant, `expense_id`, `ignored`, tombstone). A deleted purchase is a tombstone with its content erased. Disconnect (E-43, "Koppla från") revokes the session at Enable Banking (best effort), erases the session id and account uid, deletes the purchases neither linked nor ignored, and keeps the linked and ignored ones unless `erase_handled` is set; expenses stay; reconnecting reuses the connection. A migration test upgrades a v3 database in use. | One card each makes one connection per user and provider enough; a separate sequence keeps private rows out of the household stream; tombstones let a phone learn of deletions; keeping the user's decisions on disconnect stops a reconnect from offering purchases already handled. | SPEC §5.1, §9.12 CP-12, §14.1, §14.1.3, §14.3.3, §14.3.4 E-40, E-43, AC-22.12 |
@@ -185,4 +185,14 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| D-178 | 2026-10-24 | Windowed history fetch (revises D-165's `strategy=longest` retry and CP-7's paging). K0: every transactions response of the SJ Prio card holds at most 100 transactions and never a `continuation_key` (90 days: 100 rows covering only the newest 60 days; 180 days with `strategy=longest`: 100 rows, a mixed selection; 30 days: 52; 7 days: 12; five consecutive 30-day windows: 67, 85, 55, 49, 52, 308 distinct). Rule: a fetch's range is split into consecutive windows of at most 30 days, newest first, each asked with `date_from` and `date_to`; a window that returns 100 or more transactions is split in half (the newer half takes the odd day) and both halves are asked again, down to one day; a one-day window still at 100 is kept and counted as truncated; a `continuation_key`, if ever returned, is still followed; `strategy` is never sent; `WRONG_TRANSACTIONS_PERIOD` for a window skips it and every older one and the fetch succeeds; rows are kept as returned (they may be dated one or two days outside their window) and de-duplicated across windows by `provider_ref`, the newer window's copy winning; any other error ends the fetch with CP-14's error after applying the windows already fetched; only a complete fetch (every window answered, none truncated) deletes vanished pending purchases. The `card_fetch` log line gains the counts of requests, split and truncated windows, and `booked_by_ref` / `booked_by_match` (pending purchases booked under their own reference or taken over by CP-8's secondary rule). | 30 days stays under the cap with margin on this card (the busiest measured month was 85 rows) and costs 6 requests for the 180-day first fetch and one for an incremental fetch, so a background fetch stays one bank access; halving handles a busier month without a new constant; a cut response is otherwise silent, so the count is the only signal; deleting a pending purchase on an incomplete picture would lose a real purchase. | SPEC §8.7, §9.12 CP-3, CP-4, CP-7, CP-8, §11.2 K1; PLAN-K1 |
| D-179 | 2026-10-24 | Transaction mapping from K0's field survey (100 transactions; revises D-168's merchant and date order). Merchant: the `remittance_information` lines joined with a space (K0: exactly one line on every row, the merchant text), else `creditor.name` (K0: never present), else "Kortköp". `purchase_date`: `booking_date`, else `value_date` (pending rows have no booking date), else `transaction_date` (K0: never present); a row with none is skipped; `booking_date` stored when booked. `provider_ref` stays `entry_reference` (K0: on every row, pending included, unique, stable); the fallback hash uses `purchase_date`. `bank_transaction_code` (K0: `PURCHASE` 92, `PAYMENT` 5, `UNKNOWN` 2, `FEE` 1) is not used: the direction alone decides, so a debit `FEE` (a card fee) or `PAYMENT` is a purchase to handle that the holder turns into an expense or ignores, and a credit is listed under "Alla" as before. Never stored, logged or sent to a client: `debtor.name` (the card holder), `debtor_account` (a masked card number, scheme `CPAN`), `creditor.postal_address` (the merchant's country and town), `transaction_id`, `bank_transaction_code`. No schema change: the fields Kvitt keeps are those of migration 0004. | The fields the card actually fills; the booking date is the bank's settled date, and a pending purchase shows its value date until it is booked; a fee is money the holder paid and may share or ignore like a purchase, while a code-based rule would hide fees and payments made with the card; the holder's name and card number add nothing for the holder and are personal data the server need not keep. | SPEC §9.12 CP-7, CP-8; PLAN-K1 |
| D-180 | 2026-10-24 | Card artwork present from the start (revises D-173 and D-174, whose fallback stays for unknown image keys). The owner's SJ Prio Mastercard picture (committed `4339d01`, 1600 × 1094 with transparent margins) is cropped to the card's opaque bounding box (x 143 to 1455, y 134 to 962): 1313 × 829, aspect 1.584, transparent only at the rounded corners and in the semicircular notch on the right edge, as on the physical card; losslessly recompressed. The same file is committed at `android/feature/cards/src/main/res/drawable-nodpi/card_sj_prio_mastercard.png` and `web/src/features/cards/assets/card-sj-prio-mastercard.png`. K2 and K3 map `sj_prio_mastercard` to it from their first commit and draw it at its own aspect ratio without clipping (the picture carries its own corners and notch); the goldens use it; the owner's artwork steps are removed from PLAN-K2, PLAN-K3 and HANDOFF. The picture shows the SJ and Mastercard logos; the owner cleared that use for this private, non-distributed app on 2026-10-24 ("Licences first"). | The file exists, so neither plan needs a follow-up to re-record goldens; cropping removes margins that would shrink the card inside `CardArt`'s box. | SPEC §9.12 CP-1, §11.2 K2, K3; PLAN-K2, PLAN-K3 |
| D-181 | 2026-10-24 | History counts today (K1; revises CP-3's and CP-7's "today minus `history_days`"): a fetch's history bound is `date_from` = today − (`history_days` − 1), Europe/Stockholm, so 180 days are 180 dates, today included; the incremental bound (latest booking date minus 10 days) is unchanged. | "Today minus `history_days`" gives 181 dates and 7 windows of at most 30 days for 180; counting today gives 6 windows, as CP-7 and D-178 state. | SPEC §9.12 CP-3, CP-7; `server/crates/kvitt-server/src/cards/fetch.rs` (`range`) |
| D-182 | 2026-10-24 | The server's CS-4 first token for CP-8's secondary rule (K1) is taken with Rust's standard Unicode character classes (`char::is_alphabetic`, `char::is_numeric`, `char::to_lowercase`; `src/cards/fetch.rs` `first_token`) and without NFC normalisation. | It only compares two merchant texts from the same bank, which spells a merchant the same way in its pending and booked rows; no normalisation crate is needed. | SPEC §9.12 CP-8 |
| D-183 | 2026-10-24 | Enable Banking's errors mapped to CP-14 (K1): `EXPIRED_SESSION`, `CLOSED_SESSION`, `REVOKED_SESSION`, `SESSION_DOES_NOT_EXIST` → `consent_expired`, and the connection's status becomes `expired`; `ASPSP_RATE_LIMIT_EXCEEDED` or HTTP 429 → `rate_limited`; `ASPSP_ERROR`, `ASPSP_TIMEOUT`, HTTP 408, any 5xx or no answer → `bank_unavailable`; `WRONG_TRANSACTIONS_PERIOD` is not an error and ends the walk back (CP-7, D-178); anything else → `failed`. E-37 answers `card_bank_unavailable` (502) when `GET /aspsps` or `POST /auth` fails. | Enable Banking's documented error codes, grouped by what the user can do: renew, wait, or nothing. | SPEC §9.12 CP-14, §14.3.4 E-37; `src/cards/enable_banking/mod.rs` |
| D-184 | 2026-10-24 | Callback details (K1; revises CP-2 and refines D-162): an expired `state` stores the attempt as `expired` (CP-2 said `failed`); an already used one leaves the attempt unchanged; both show the failure page. The attempt is claimed atomically (one `UPDATE … WHERE status = 'pending'`) before Enable Banking is asked for a session, so a replayed callback cannot create a second session. After the claim, an `error` → `failed`/`cancelled`, neither `code` nor `error` → `failed`/`bank_error`, a session without accounts → `failed`/`no_account` and that session is revoked at Enable Banking (best effort). | `expired` is what E-38 reports for a pending attempt past its expiry anyway; the claim closes a race between two callbacks with the same `state`; an unused session should not stay open at the bank. | SPEC §9.12 CP-2, §14.3.4 E-38, E-39; `src/cards/callback.rs` |
| D-185 | 2026-10-24 | Fetch details (K1; refines D-165 and D-178): at start-up the poller runs the latest slot already passed once, subject to the quota and the 2-hour rule (never a backlog); every background start counts towards the 4 per 24 hours whatever its outcome; after `WRONG_TRANSACTIONS_PERIOD` the deletion of vanished pending purchases covers only the windows that answered; when a window is split at the 100-row cap, the cut response's rows are discarded and the two halves are asked again. | A start counts as an access at the bank even when it fails; deleting over unanswered dates would remove purchases the bank never reported on; the halves' rows replace the cut ones, so no partial copy is applied. | SPEC §9.12 CP-4, CP-7; `src/cards/poller.rs`, `src/cards/fetch.rs` |
| D-186 | 2026-10-24 | PSU headers (K1; refines D-165): each header is sent only when the app's request carries a value for it; the ASPSP's details from `GET /aspsps` (its `required_psu_headers` and `maximum_consent_validity`) are cached for 24 hours; when `/aspsps` fails, a user-present fetch sends only `Psu-Ip-Address` and `Psu-User-Agent`. | An empty header is not a value; the ASPSP list changes rarely; the two headers are what K0 saw accepted. | SPEC §9.12 CP-5; `src/cards/enable_banking/mod.rs` (`ASPSP_CACHE`) |
| D-187 | 2026-10-24 | Endpoint details (K1): E-41 with the current `history_days` answers the unchanged connection with its existing `card_version`; E-43 on a disconnected connection makes no new version, but `erase_handled=true` still erases its linked and ignored purchases; E-44 on a credit purchase is `validation_failed` with `field` the key wrongly set (`expense_id` when not null, else `ignored`); `CardAttempt.failure` stays a plain nullable string in `contracts/openapi.yaml` (§14.3.3), not an enum. | Idempotent writes make no new versions (D-034); a second disconnect may be the way to erase the handled purchases; `failure` values may grow without a contract change. | SPEC §14.3.3, §14.3.4 E-38, E-41, E-43, E-44 |
| D-188 | 2026-10-24 | Placement and infra (K1): the card wire types live in `src/wire/card.rs`, the card row helpers in `src/cards/mod.rs`; the rate limiter puts the client IP it determined (§8.5, §8.6) in a request extension (`ClientIp`), from which the user-present fetch takes `Psu-Ip-Address`; the Pi's `compose.yaml` mounts `./secrets/cards` with `create_host_path: false`, so `docker compose up` refuses to start until `secrets/cards/` exists, even with no card configured (the owner creates it, mode 0700, before deploying K1's image). | One wire module per area as before; one source for the client IP; a missing folder must not become a root-owned directory Docker creates (D-117). | SPEC §9.12 CP-5, §10.2, §14.7; `src/api/rate_limit.rs`, `infra/pi/compose.yaml` |
| D-189 | 2026-10-24 | Unused (reserved for K1; K1 needed D-181 to D-188). | | |
| D-190 | 2026-10-24 | Unused (reserved for K1; K1 needed D-181 to D-188). | | |
| D-211 | 2026-10-24 | Image builds on the Mac (revises D-156 point 5 and D-104's "fallback" wording). `infra/build-images.sh v1.0.N` on the owner's Mac with Podman is the normal way to build and push `kvitt-server` and `kvitt-backup`; the Gitea Actions workflow on the Pi 3 runner stays as the fallback (about three hours per run, disk pressure; the owner cancels the runs it queues). Tag: after `git fetch --tags`, the next `v1.0.N` after the highest `v1.0.*` tag on `origin`, also pushed as a git tag on the built commit (`v1.0.3` = `8fa9198`). Registry from the Mac: `gitea.lan` on port 80, plain HTTP; `infra/secrets/registry.env` (git-ignored) holds `KVITT_REGISTRY=gitea.lan/jacobh2`, the user and a `write:package` token; the Pi's `.env` keeps `gitea.lan:3001/jacobh2`, the port it reaches locally; both name the same registry. Podman needs the registry marked insecure in `~/.config/containers/registries.conf.d/kvitt.conf` on the Mac and `/etc/containers/registries.conf.d/kvitt.conf` inside the Podman machine (`[[registry]] location = "gitea.lan" insecure = true`), owner's allowance 2026-10-24. Script fixes: the server build gets `KVITT_VERSION_CODE` as in the workflow (`f15fa23`); Podman detection without a pipe (`8fa9198`: `grep -q` under `pipefail` closed the pipe, `docker version` died of SIGPIPE and the script chose docker). Deployed and verified by the owner 2026-10-24: `v1.0.3` on the Kvitt Pi (`server` and `backup` healthy); the web client at `https://kvitt.suorra.duckdns.org/app/`, a laptop browser paired with a device invite; "All seems to work" for Home and an account, add, edit and delete an expense, CSV export, record and delete a payment, analytics with category drill-down and Insights, the settings pages, revoking the browser from the phone, and the Android Insights section on S-11 against the new server. This answers the open question "image-build location". | A cold arm64 server build took 48 s on the M4 (2026-10-18, before the web stage) against 2 h 42 min on the 1 GB Pi 3 (run 69), whose disk fills during builds; the workflow is kept because it needs no Mac. One tag sequence for both paths keeps `KVITT_TAG` monotonic whichever path built it. | SPEC §10.3, §10.10, §11.2 W1 to W7, §11.3, §11.4, §15; `infra/README.md` (Layout, How it fits together, the image-build sections, "Pi: what the owner does" steps 2 and 7, Owner decisions); `docs/HANDOFF.md` |