D-044 adds nine Swedish Splitwise category names to the SPEC §10.8 table and splitwise_categories.csv. D-045 records the reconciliation against the totals rows; D-046 the importer CLI semantics and exit codes. M9 marked landed at ab298ab, the M11 rehearsal recorded, server/README.md and prompts/README.md updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
244 KiB
Kvitt: specification
Version 0.7 (2026-10-18). Schema version 1. 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.
Conventions used throughout:
- "Owner" is Jacob, the admin user. "Partner" is the other user. "User" is either of the two.
- "Öre" is 1/100 SEK. All money is an integer number of öre.
- "bp" is basis points. 10000 bp is 100 %.
- Timestamps are RFC 3339 UTC strings in exactly this form:
2026-10-17T06:00:00.000Z(millisecond precision, literalZ). This form sorts correctly as a string. - Dates without time are ISO 8601
YYYY-MM-DD. Months ("periods") areYYYY-MM. ISO weeks areYYYY-Wwwwith the ISO week-numbering year (week starts Monday). - Ids are UUIDv7, lowercase, hyphenated, 36 characters, stored and sent as strings.
- JSON keys are
snake_case. Enum values are lowercasesnake_casestrings. - "Must", "must not" and "may" are binding.
1. Overview & goals
Kvitt is a shared-expense app for one household of exactly two people living in Sweden: the owner and his spouse. It replaces Splitwise for them. It tracks who paid for what, who owes whom, and how the household spends money over time.
It consists of:
- A native Android app (Kotlin, Jetpack Compose) on two Samsung phones (Galaxy S26 and Galaxy S26+, Android 16). The app keeps a full copy of the household's data in a local Room database and works fully offline for expenses and settlements.
- A small Rust server (axum, SQLite) in Docker on a Raspberry Pi 3 at the owner's home, reachable at
https://icehack.asuscomm.com:8443. The server stores the authoritative copy, syncs the phones, creates recurring expenses, sends push notifications, keeps an audit log and hosts the APK for in-app updates.
Goals for v1:
- G-1 Correct money. Balances are exact to the öre and computed identically on phone and server, guaranteed by shared test vectors.
- G-2 Offline first. Creating, editing and deleting expenses and settlements never waits for the network. Two weeks offline followed by a sync loses nothing.
- G-3 Flexible splitting. Per-account default ratios (50/50 to start, configurable such as 60/40), custom ratios, exact amounts, or 100 % on one person.
- G-4 Clear settling. One global balance, a per-account breakdown, and a settle-up flow that proposes how a payment is spread over accounts.
- G-5 Insight. Monthly and weekly spending by category and by account, computed on the phone.
- G-6 Trust. Every change to an expense or settlement is visible as a timeline of who did what.
- G-7 Low-maintenance hosting. One docker-compose stack on a 1 GB Raspberry Pi, nightly off-site backups, no cloud services except Firebase Cloud Messaging and Google Drive for backups.
- G-8 Bilingual. Every user-visible string exists in Swedish and English. The phone's locale decides: Swedish when the device language is Swedish, English otherwise.
- G-9 History. Six years of Splitwise history can be imported once, repeatably without duplicates.
2. Non-goals
Not in v1. Agents must not build any of these, even partially:
- Receipt photos or receipt scanning. Profile pictures are the only file upload.
- Any card, bank, Spendwise or other financial-institution integration.
- Multiple currencies or exchange rates. All amounts are SEK. (The importer has a one-off conversion option, §4 FR-17.)
- Joint accounts, shared cards or "paid from the shared pot" as a payer. The payer is always one of the two users.
- More than two users, more than one household, user deletion.
- Passwords, password reset, email, SMS, OAuth sign-in.
- Play Store distribution.
- CSV or any other export.
- A web client. The only HTML the server serves is the invite landing page (§4 FR-2).
- Server-side analytics or reporting endpoints.
- Debt simplification (meaningless with two users) and netting several accounts in opposite directions inside one settlement.
- Editing accounts, categories, recurring templates, profiles or invites while offline.
- Tablet, foldable or landscape-specific layouts. Portrait phone only; landscape must not crash.
- Widgets, Wear OS, iOS.
- In-app language picker. The phone locale decides.
- Comments or chat on expenses.
3. Users & core user stories
3.1 Roles
| Role | Who | Count | Extra rights |
|---|---|---|---|
admin |
The owner | exactly 1 | Creates invites (partner and device), cancels invites, revokes any device |
member |
The partner | 0 or 1 | Revokes own devices other than the one in use |
Everything else (expenses, settlements, accounts, categories, recurring templates) is open to both users with identical rights. Either user may edit or delete anything the other created. Each user edits only their own display name and picture.
A user has one or more devices. v1 expects one phone each, but the model allows any number of devices per user without change.
3.2 User stories
- US-1 As the owner I set up the server once from the command line and pair my phone with a one-time code.
- US-2 As the owner I invite my partner with a link; my partner installs the app from the link, picks a name and a picture, and is paired.
- US-3 As a user I add an expense in a few taps: amount, description, category, account; the split defaults to the account's ratio and the payer defaults to me.
- US-4 As a user I record an expense my partner paid, so they do not have to.
- US-5 As a user I split an expense differently: a custom ratio, exact amounts, or the whole amount on one of us.
- US-6 As a user I see one number saying who owes whom overall, and the same per account.
- US-7 As a user I settle up: I enter what I paid my partner, the app proposes which accounts it settles (oldest first), I adjust and confirm.
- US-8 As a user I settle one account from that account's screen.
- US-9 As a user I create expense accounts ("Vardag", "Huset", "Japan 2027"), give each a default ratio, and archive them when done.
- US-10 As a user I set up a recurring expense (rent, streaming) that appears automatically each month.
- US-11 As a user I see how much we spent per month or week, by category or by account, as a household total or as my share.
- US-12 As a user I add and edit expenses on a plane or abroad without a connection, and everything syncs when I am back.
- US-13 As a user I get a notification when my partner adds, changes or deletes something, and I can see in the expense who changed what.
- US-14 As a user I am told when a new app version exists and install it from inside the app.
- US-15 As the owner I import our Splitwise history once.
- US-16 As the owner I can restore everything from a backup if the Pi dies.
- US-17 As the owner I can remove a lost phone's access.
4. Functional requirements (v1)
Each requirement has acceptance criteria (AC). An AC is satisfied only when a test (unit, integration, screenshot or instrumented) or a documented manual check in the milestone's gate demonstrates it. Limits and rules referenced as §9.x are stated only there.
FR-1 Server bootstrap
The server starts with an empty database. The household is created from the command line on the Pi.
- AC-1.1
kvitt-server bootstrapon a database with no users creates an invite of kindadmin_setup, prints its code (format §8.3), its landing linkhttps://icehack.asuscomm.com:8443/i/<CODE>(the base URL fromKVITT_PUBLIC_URL), its expiry, and exits 0. - AC-1.2
kvitt-server bootstrapon a database that has users printsalready initializedto stderr and exits 2. It creates nothing. - AC-1.3 Running
kvitt-server bootstrapagain before the first code is used cancels the previousadmin_setupinvite and prints a new one. - AC-1.4 Accepting the
admin_setupinvite (FR-2) creates theadminuser with the given display name and the first device, and returns a device token. - AC-1.5
GET /setup/statusreturns{"initialized": false}before AC-1.4 and{"initialized": true}after. - AC-1.6
kvitt-server pair-device --user <user_id>creates adeviceinvite for an existing user and prints it like AC-1.1. This is the recovery path when the owner has lost every paired phone.
FR-2 Invites and pairing
- AC-2.1 The admin creates a
partnerinvite from Settings. It is refused withhousehold_fullwhen two users exist. Creating a newpartnerinvite cancels any pending one. - AC-2.2 The admin creates a
deviceinvite for either user from Settings (to pair a second phone). - AC-2.3 An invite is single-use and expires 72 hours after creation. An expired, cancelled, used or unknown code is refused with
invite_invalid; the response does not reveal which. - AC-2.4 Accepting an invite takes the code, a device name and (for
admin_setupandpartner) a display name. It returns a bearer token, the user, the device and the household's users. For apartnerinvite it creates thememberuser. - AC-2.5
GET /i/<CODE>serves an HTML landing page in Swedish if the request'sAccept-Languagepreferssv, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking tokvitt://invite?server=<urlencoded KVITT_PUBLIC_URL>&code=<CODE>, the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data. While no APK is published (E-5 would answernot_found) the page omits the download button. The page is sent withReferrer-Policy: no-referrerandCache-Control: no-store. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404". - AC-2.6 The app opens from
kvitt://invite?server=…&code=…and fromhttps://icehack.asuscomm.com:8443/i/<CODE>(unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. - AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with
https://icehack.asuscomm.com:8443in release builds) and the code. Codes are normalised per §8.3 before sending. - AC-2.8 Release builds accept only
https://server URLs. Debug builds also accepthttp://forlocalhost,10.0.2.2and private LAN addresses (10.*,172.16.*-172.31.*,192.168.*). - AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android
POST_NOTIFICATIONS). - AC-2.10 A
partnerpairing continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen. - AC-2.11 The admin sees pending invites (kind, created, expiry) in Settings and can cancel each.
- AC-2.12 More than 10 failed invite accepts from one client IP within 60 minutes yield
rate_limitedfor the rest of that window. A failed accept is any 4xx answer of E-3 other than 429. After the tenth failure, further accepts from that IP are refused withrate_limiteduntil 60 minutes after the first counted failure; the window is fixed from the first counted request, not sliding (D-031).
FR-3 Devices and revocation
- AC-3.1 Settings lists devices: the admin sees all devices of both users; a member sees their own. Each row shows device name, owner, paired date, last seen date, app version, and "This device" for the current one.
- AC-3.2 The admin can revoke any device except the one in use (
cannot_revoke_current_device). A member can revoke their own devices except the one in use. - AC-3.3 A revoked device's next request gets HTTP 401
device_revoked. The app then deletes its token, its local database and its cached pictures, and shows the "Device removed" screen with a "Pair again" button. - AC-3.4 A revoked device never receives pushes again.
- AC-3.5 Devices are never deleted from the database; revocation sets
revoked_atand clears the device'sfcm_token(D-034).
FR-4 Profiles
- AC-4.1 Each user has a display name (limits §9.6) and an optional profile picture.
- AC-4.2 A user edits their own display name in Settings (online only). The change reaches the other phone through sync.
- AC-4.3 A user sets their own picture from the Android photo picker (no camera capture in v1) or removes it. Upload is online only.
- AC-4.4 The server accepts JPEG, PNG and WebP up to 5 MiB and at most 8000 × 8000 pixels, applies EXIF orientation, centre-crops to a square, re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128, strips all metadata, and increments the user's
picture_version(the first upload is version 1; after a removal the next version follows the highest one ever used, §10.4, D-030). PNG and WebP transparency is flattened onto white (D-034). Other input is refused withimage_invalid,unsupported_media_typeorpayload_too_large. - AC-4.5 Where no picture exists, the app shows the user's initials (first letter of the first two words of the display name, uppercase) on a colour derived from the user id (UX sprint defines the two colours).
- AC-4.6 The app caches pictures on disk (Coil) keyed by user id and
picture_version, so they show offline.
FR-5 Household gate
- AC-5.1 Until the partner has joined, the admin's Home screen shows "Waiting for your partner" with the "Invite partner" action, and creating accounts, expenses, settlements and recurring templates is not offered.
- AC-5.2 The server refuses account creation, recurring template creation and any expense or settlement mutation with
household_incompletewhile fewer than two users exist. (The rule exists because every split refers to both users.)
FR-6 Expense accounts
- AC-6.1 Either user creates an account (online) with a name and a default split ratio. The editor starts at 50/50. Ratios are entered as percent with up to two decimals per user, the second user's value always being 100 minus the first; stored as bp (§9.1).
- AC-6.2 Account names are unique among non-deleted accounts, compared case-insensitively after trimming (Unicode lowercase). A duplicate is refused with
name_taken. - AC-6.3 Either user renames an account or changes its default ratio (online). A ratio change affects only expenses created afterwards; existing expenses keep the shares they were saved with.
- AC-6.4 Either user archives or unarchives an account (online), even with a non-zero balance. Archived accounts are hidden from the account pickers for new expenses and recurring templates, shown in a collapsed "Archived" section on Home, still count toward the global balance, still appear in analytics and can still be settled. Hiding is a picker rule only: the server accepts archived accounts and categories in E-28 and E-29 (as it does in sync pushes, §9.10).
- AC-6.5 Either user deletes an account (online) only when no non-deleted expense, no allocation of a non-deleted settlement and no non-deleted recurring template references it. Otherwise
account_not_empty. Deletion is a soft delete (tombstone) so phones learn of it through sync. - AC-6.6 Accounts are listed by
created_atascending, the order used by the settlement proposal (§9.4). - AC-6.7 The account create and edit actions are disabled with a "Needs connection" hint when the phone is offline (§9.9).
FR-7 Categories
- AC-7.1 One global, editable category list. A category has a name, an icon token (§9.7) and a colour token (§9.7).
- AC-7.2 The database is seeded with exactly these ten default categories (fixed ids, §14.1), each shown with its localised default name until a user renames it:
default_key |
English name | Swedish name | icon |
color |
sort_order |
|---|---|---|---|---|---|
groceries |
Groceries | Mat & hushåll | shopping_cart |
green |
10 |
restaurants |
Restaurants | Restaurang & café | restaurant |
orange |
20 |
home |
Home | Hem | home |
brown |
30 |
utilities |
Utilities | Räkningar | bolt |
yellow |
40 |
transport |
Transport | Transport | directions_car |
blue |
50 |
travel |
Travel | Resor | flight |
teal |
60 |
entertainment |
Entertainment | Nöje | movie |
purple |
70 |
health |
Health | Hälsa | medical_services |
red |
80 |
shopping |
Shopping | Shopping | shopping_bag |
pink |
90 |
other |
Other | Övrigt | category |
grey |
100 |
- AC-7.3 Either user creates a category (online) with a name, icon and colour. New categories get
sort_order= the maximumsort_orderover non-deleted categories + 10 (D-034). - AC-7.4 Either user renames, re-icons, recolours or reorders a category (online). Renaming a default category stores the custom name; "Reset name" (only for default categories) clears it so the localised name shows again.
- AC-7.5 Either user archives or unarchives a category. Archived categories are not offered for new expenses or templates but stay on existing ones and in analytics.
- AC-7.6 A category is deletable only if no non-deleted expense or non-deleted recurring template uses it (
category_in_useotherwise). Default categories (with adefault_key) cannot be deleted (category_is_default); they can be archived. Deletion is a soft delete. - AC-7.7 Category names have no uniqueness rule.
FR-8 Expenses
- AC-8.1 An expense has: account, amount (öre), description, category, date, payer, split, notes, created-by, last-modified-by, timestamps. Limits in §9.6.
- AC-8.2 Either user creates, edits or deletes any expense, online or offline. The id is a client-generated UUIDv7.
- AC-8.3 The payer is either user. The editor defaults the payer to the current user.
- AC-8.4 The date is any date within §9.6 limits, past or future. It defaults to today in the phone's time zone. This date is the only date used in analytics and lists.
- AC-8.5 Split modes (enum §9.1):
default(the account's default ratio, copied into the expense when saved),ratio(a custom ratio),exact(exact öre per user),full(the whole amount borne by one chosen user, who may be the payer or not). Share computation per §9.1. - AC-8.6 The editor defaults: account = the account last used on this phone if still active, else the oldest active account; category = the category last used in that account on this phone if still active, else
other; split modedefault. - AC-8.7 Exact mode: the user types one user's share; the other user's share is the amount minus it and is shown read-only. A typed share larger than the amount blocks saving with an inline error.
- AC-8.8 Changing the account of an expense in mode
defaultre-copies the new account's ratio. In other modes the split is kept. - AC-8.9 Deleting asks for confirmation and is a soft delete. Deleted expenses disappear from lists, balances and analytics on both phones after sync.
- AC-8.10 Expense lists show, per row: category icon and colour, description, date, amount, and a one-line effect from the viewer's side ("You lent 205,00 kr", "You borrowed 205,00 kr", "Not involved" for
fullon the payer, matching §13 copy). Rows are ordered by date descending, thencreated_atdescending. - AC-8.11 An expense created from a recurring template is an ordinary expense in every way (editable, deletable) and shows "Recurring" with the template name in the detail view.
FR-9 Recurring expenses
- AC-9.1 Either user creates, edits, pauses, resumes or deletes a recurring template (online). A template holds the expense fields (account, amount, description, category, payer, split mode and shares, notes) plus
day_of_month(1-31),start_periodand an optionalend_period. - AC-9.2 The server creates one expense per template per period at 06:00 Europe/Stockholm on the template's day. Days 29, 30 and 31 clamp to the last day of shorter months (day 31 in April creates on 30 April; day 29 in February 2027 creates on 28 February).
- AC-9.3 Generation rules (catch-up after downtime, idempotency, split resolution) are in §9.5.
- AC-9.4 Editing a template changes only expenses generated afterwards.
- AC-9.5 A generated expense that a user deletes is not recreated.
- AC-9.6 Each generated expense writes an audit entry with
source=recurringand sendsrecurring_expense_createdto every device of both users. - AC-9.7 The template list shows each template's description, amount, account, day of month, paused state and next occurrence date (computed by the server,
next_occurrence_date). - AC-9.8 The editor's default
start_periodis the current month ifday_of_monthis today or later in the phone's time zone, else the next month. The user may choose astart_periodup to 12 months in the past (backfill) and any month in the future.
FR-10 Balances
- AC-10.1 Home shows one global balance between the two users from the viewer's side: " owes you X", "You owe X" or "All settled up".
- AC-10.2 Home lists every active account with its balance in the same wording, and archived accounts in a collapsed section.
- AC-10.3 The account screen shows that account's balance and its expenses and settlements interleaved by date.
- AC-10.4 Balances are computed on the phone from the local database with the domain function of §14.5, include pending (unsynced) local changes, exclude deleted rows, and update immediately after a local write.
- AC-10.5 The global balance equals the sum of the per-account balances, archived accounts included.
FR-11 Settlements
- AC-11.1 A settlement is a payment from one user to the other with an amount, a date, an optional note and one or more per-account allocations. Limits in §9.6.
- AC-11.2 The allocations' sum must equal the settlement amount. Each allocation is positive. An account appears at most once.
- AC-11.3 Global settle-up (from Home): the direction defaults to "the user who owes pays", the amount defaults to the absolute global balance (or empty when it is zero). The app computes the proposal with
propose_allocation(§9.4, §14.5) and shows one editable row per proposed account plus "Add account" to add any other account (active or archived). The confirm button is enabled only when the allocations sum to the amount; the remaining difference is shown live. - AC-11.4 Per-account settle-up (from the account screen) creates a settlement with exactly one allocation for that account; the amount defaults to the absolute account balance and the direction to "the user who owes in this account pays".
- AC-11.5 An allocation larger than the payer's current debt in that account is allowed (over-settling flips the account's sign) and is marked with a warning per row ("This settles more than is owed in "). The confirm dialog repeats the warning.
- AC-11.6 Settlements can be created, edited and deleted (soft delete) offline, by either user, like expenses. When editing, the proposal and warnings are computed with the edited settlement excluded from the balances.
- AC-11.7 A settlement row in lists shows " paid X" and the account count when more than one allocation exists. In an account's list (S-5) X is the settlement's allocation to that account, not the settlement total; everywhere else X is the total.
- AC-11.8 The server's
POST /settlements/proposalreturns exactly what the phone'spropose_allocationreturns for the same server state (verified by shared vectors). The phone always computes locally; the endpoint exists for parity tests and server-side tooling.
FR-12 Audit log and timeline
- AC-12.1 The server appends an audit entry for every accepted create, update and delete of an expense or settlement, from the app, the scheduler or the importer. Entries are never updated or deleted.
- AC-12.2 An entry records entity type, entity id, action, source, actor user, actor device (null for scheduler and importer), client time of the change, server time, and a field diff (§14.4.7).
- AC-12.3 Audit entries reach both phones through sync pull.
- AC-12.4 The expense and settlement detail views show a timeline, newest last, one line per entry, such as "Anna created this · 3 Oct 14:02", "You changed the amount from 400,00 kr to 412,00 kr · 3 Oct 18:30", "Anna deleted this". Diffs show field names in the UI language; ids are resolved to names (accounts, categories, users).
- AC-12.5 A pending local change not yet synced shows as a last timeline line "Your change is waiting to sync".
FR-13 Push notifications
- AC-13.1 When the server accepts a create, update or delete of an expense or settlement from a device of user U, it sends a push to every non-revoked device of the other user that has an FCM token. Types and payload in §14.6.
- AC-13.2 Recurring generation sends to every non-revoked device of both users.
- AC-13.3 The importer sends no pushes.
- AC-13.4 One
POST /syncthat produces more than 3 notifiable changes sends onesync_batchpush instead of one per change. - AC-13.5 The app shows the notification only after it has synced the change, built from local data in the phone's language (title and body copy in §13). If that sync fails within 20 seconds, it shows a generic text without amounts or descriptions.
- AC-13.6 Tapping a notification opens the detail view of the changed expense or settlement, or Home for
sync_batchand deletions. - AC-13.7 The app registers its FCM token after pairing and whenever Firebase rotates it (
PUT /devices/current/push-token). - AC-13.8 No personal content (names, amounts, descriptions, notes) is placed in the FCM payload.
FR-14 Analytics
- AC-14.1 Analytics are computed on the phone from Room with the pure function of §14.5.4; the server has no reporting endpoint. They work offline and include pending local changes.
- AC-14.2 Range presets:
this_month,last_3_months,last_6_months,last_12_months,this_year,all_time,custom(start and end date pickers). "Last N months" means the current month and the N-1 months before it, whole months. Defaultlast_6_months. - AC-14.3 Granularity:
monthorweek(ISO, Monday start). Defaultmonth. - AC-14.4 Measure:
household(sum of full expense amounts, whoever paid) ormy_share(sum of the viewer's shares). Defaulthousehold. - AC-14.5 Group by:
categoryoraccount. Defaultcategory. - AC-14.6 Charts: (1) spend over time, a stacked bar per bucket coloured by group; (2) category breakdown for the range, horizontal bars sorted by amount descending with amount and percent; (3) account breakdown for the range, same form. A total for the range is shown above the charts. Settlements are never counted as spend.
- AC-14.7 Every bucket in the range appears, empty ones as zero. Buckets at the edges of a custom range count only dates inside the range.
- AC-14.8 Tapping a bar or a breakdown row opens a list of the expenses behind that number.
- AC-14.9 Deleted expenses are excluded; archived accounts and categories are included.
- AC-14.10 Percentages are rounded half-up to one decimal and may not sum to exactly 100.
FR-15 Offline and sync
- AC-15.1 The phone holds a full local copy of: users, accounts, categories, recurring templates, expenses, settlements and audit entries (Room, §14.2).
- AC-15.2 Expenses and settlements can be created, edited and deleted offline. Accounts, categories, recurring templates, profile, pictures, invites and devices need the server; their actions are disabled offline with "Needs connection".
- AC-15.3 Sync runs on app start and every return to the foreground, 2 seconds after the last local write (debounced), every 15 minutes in the background through WorkManager (network constraint), and on every FCM data message (expedited one-off work, waited for up to 20 seconds, AC-13.5). Only one sync runs at a time. WorkManager names in §14.4.1 (D-028).
- AC-15.4 The app shows a "Last synced" relative time and a pending-changes count on Home, and a sync status screen (§13).
- AC-15.5 Conflicts resolve by last-write-wins per row on the client
updated_at, deletes are final, and rejected changes are shown to the user (§14.4). - AC-15.6 A phone offline for 14 days with 200 local changes syncs all of them on reconnect (integration test with a scripted clock).
FR-16 In-app update
- AC-16.1 The server hosts the latest signed release APK and its metadata (§10.6).
- AC-16.2 Release builds call
GET /apk/lateston every cold start and at most once per 6 hours while resumed. Whenversion_codeis greater than the installed one, Home shows an "Update available" banner with the version name. - AC-16.3 "Update" downloads the APK to the app's cache, verifies the size and SHA-256 from the metadata, and installs through
PackageInstaller. The first time, Android asks the user to allow "Install unknown apps" for Kvitt; the app explains this beforehand:UpdateRepositoryemitsUpdateState.NeedsInstallPermissiononce (the explanation-shown flag inprefs.preferences_pb, §14.2.2), and the nextinstall()proceeds to the system prompt. - AC-16.4 A verification failure deletes the file and shows "Download failed, try again".
- AC-16.5 Debug builds never check for updates.
FR-17 Splitwise import
- AC-17.1 A one-off CLI
kvitt-import(cratekvitt-import) imports Splitwise CSV exports, one file per Splitwise group, each into a Kvitt account named on the command line. Details §10.8. - AC-17.2 Re-running the same import creates nothing new (stable import key, §10.8).
- AC-17.3 Splitwise categories map to Kvitt categories by the fixed table in §10.8; unknown names map to
otherand are listed in the summary. - AC-17.4 Splitwise payments become settlements with one allocation.
- AC-17.5
--dry-runprints the summary (rows read, expenses, settlements, skipped duplicates, unknown categories, non-SEK rows, the reconciliation of §10.8) and writes nothing; it runs the whole import transaction and rolls it back, with the same exit status a real run would have (D-046). - AC-17.6 Imported rows produce audit entries with
source=importand no pushes.
FR-18 Backups and restore
- AC-18.1 Every night at 03:30 Europe/Stockholm the backup sidecar writes a consistent SQLite snapshot and a pictures archive, gzips them and uploads them to the owner's Google Drive folder with rclone. Nothing on Drive is ever deleted or overwritten.
- AC-18.2 A restore drill following §10.9 restores a backup on the Pi and both phones then sync correctly, documented in
docs/validation/restore-drill.md.
FR-19 Localisation and formatting
- AC-19.1 Every user-visible string is a resource in
values/strings.xml(English, the fallback) andvalues-sv/strings.xml(Swedish). No string literal appears in feature code. A lint or test in the Android gate fails on a key missing from either file. - AC-19.2 Money formatting per §9.8, dates per §9.8.
- AC-19.3 Category default names and every enum shown to users have strings in both languages.
FR-20 Health
- AC-20.1
GET /healthreturns status, server version, schema version and a database check, unauthenticated, in under 100 ms on the Pi. - AC-20.2 The docker healthcheck of the
serverservice uses it.
5. Data model
This section explains the entities and their meaning. The exact server DDL is §14.1, the phone's Room entities §14.2, the wire JSON §14.3. Limits are in §9.6.
5.1 Entity overview
| Entity | Id origin | Synced to phones | Written by phones | Notes |
|---|---|---|---|---|
| User | server | yes (pull) | REST only, own profile | Exactly two in a complete household |
| Device | server | no | REST only | One per paired app install |
| Invite | server | no | REST only, admin | Single-use pairing code |
| Account | server | yes (pull) | REST only | Expense account, like a Splitwise group |
| Account default share | with account | yes (inside account) | REST only | One row per user, sums to 10000 bp |
| Category | server (defaults seeded with fixed ids) | yes (pull) | REST only | Global list |
| Recurring template | server | yes (pull) | REST only | Monthly generator |
| Recurring template split | with template | yes (inside template) | REST only | Shares for modes ratio, exact, full |
| Expense | client (UUIDv7) | yes (pull and push) | sync push | Soft delete |
| Expense split | with expense | yes (inside expense) | sync push | One row per user |
| Settlement | client (UUIDv7) | yes (pull and push) | sync push | Soft delete |
| Settlement allocation | with settlement | yes (inside settlement) | sync push | One row per account |
| Audit entry | server | yes (pull) | never | Append-only |
| Sync mutation record | server | no | never | Idempotency of pushes |
| Sync sequence, meta | server | no (epoch is sent in pull) | never | Global server_version counter, sync_epoch |
Local-only phone tables: sync_state and sync_rejections (§14.2).
5.2 Ownership of fields
server_version: an integer from one global, strictly increasing sequence. Every insert or update of a synced row (users, accounts, categories, recurring templates, expenses, settlements, audit entries) takes the next value. Child rows (shares, splits, allocations) have none; a change to a child bumps its parent. Phones never set it.created_at,updated_atof expenses and settlements are set by the phone's clock at the time of the local write and are the last-write-wins key. For server-written rows (all others, and expenses created by the scheduler or importer) they are the server clock.created_by_user_idis set by the server from the authenticated device's user when it first stores the row, never taken from the client.updated_by_user_idandupdated_by_device_idare set by the server on every accepted write.deleted_atmarks a tombstone. Tombstones are kept forever on both server and phone and are synced like any other row.archived_at(accounts, categories) hides an item from pickers; it never affects money.
5.3 Money
- Amounts are integer öre in signed 64-bit integers (Kotlin
Long, Rusti64, SQLiteINTEGER). - An expense's shares, one per user, always sum exactly to its amount.
- Ratios are integer bp per user summing to 10000.
- A user's net position in an account is: what they paid for expenses, minus their shares, plus settlement allocations they paid, minus settlement allocations they received. The two users' net positions always sum to zero. A positive net means the other user owes them that much. Formal definition §9.3.
5.4 Relationships
- An expense belongs to one account and one category, has one payer and exactly two splits (one per user), and optionally references the recurring template and period it was generated for.
- A settlement has a payer (
from_user_id), a recipient (to_user_id) and one or more allocations, each to one account. - An audit entry references one expense or settlement by type and id.
- A device belongs to one user. An invite of kind
devicetargets one user. - References from synced rows point only at rows that are never hard-deleted, so a reference always resolves on the server. On the phone a reference may be briefly unresolved during a pull (§14.2); the UI shows such a row only after its references resolve.
5.5 What is not stored
- No balances, totals or analytics are stored anywhere; they are always computed from rows.
- No exchange rates, no currencies (SEK is implicit).
- No passwords. Tokens and invite codes are stored only as SHA-256 hashes.
6. Architecture & stack
6.1 System view
Samsung S26 (owner) Samsung S26+ (partner)
┌──────────────────┐ ┌──────────────────┐
│ Kvitt app │ │ Kvitt app │
│ Compose UI │ │ │
│ domain (pure) │ │ │
│ Room (full copy) │ │ │
└───────┬──────────┘ └────────┬─────────┘
│ HTTPS :8443 (bearer token) │
▼ ▼
router: external 8443 ──► Kvitt Pi :8443 (80/443 go to the Home Assistant Pi)
┌───────────────────────── Raspberry Pi 3, docker compose ─────────────────────────┐
│ caddy (TLS, :8443) ──► server (axum, :8080) ──► /data/kvitt.sqlite (WAL) │
│ cert-sync (ssh pull from HA Pi nightly, reload caddy) │
│ backup (sqlite .backup + gzip nightly ──► rclone ──► Google Drive) │
└───────────────────────────────────────────────────────────────────────────────────┘
│ FCM HTTP v1 (service account) ▲ Pi pulls images
▼ │
Firebase Cloud Messaging ──► phones owner's M4 Mac: build linux/arm64 images ──► Gitea registry
6.2 Android app
| Concern | Choice | Why |
|---|---|---|
| Language | Kotlin 2.x, latest stable | Owner's requirement |
| UI | Jetpack Compose, Material 3; Material 3 Expressive components where they are stable. The Material 3 artifact is pinned explicitly in the catalog (1.4.0 at M2b) alongside the Compose BOM | Native, modern, screenshot-testable |
| Min / target SDK | minSdk 36 (Android 16); compileSdk and targetSdk 37 (the latest stable API when M2b pinned them, in libs.versions.toml) |
Both phones run Android 16; no legacy paths |
| Local DB | Room 3 (androidx.room3, 3.0.3 at M2b), KSP only, an SQLiteDriver with androidx.sqlite:sqlite-bundled, exported schema (D-012) |
Full offline copy; migrations with tests |
| DI | Hilt. :data's DataModule provides implementations only; the bindings of the :core:model interfaces to them live in DataBindingsModule (@DisableInstallInCheck), which :app includes in the release graph, so the debug fakes do not duplicate bindings (D-041) |
Standard, works with WorkManager and ViewModels |
| HTTP | Ktor client (OkHttp engine), kotlinx.serialization JSON | Kotlin-first, multiplatform-style DTOs |
| Background | WorkManager (periodic 15 min sync, expedited sync) | Survives process death, respects Doze |
| Dates | kotlinx-datetime | Allowed in pure modules (no java.time) |
| Images | Coil (Compose) with an auth header interceptor | Disk cache for offline avatars |
| Charts | Vico (Compose). If Vico's current release cannot draw a stacked bar chart with Material 3 theming, hand-roll the three charts with Compose Canvas in :core:design |
Mature Compose chart library; fallback is small |
| Push | Firebase Cloud Messaging, only inside :adapter:fcm |
Free tier; vendor isolated per CLAUDE.md |
| Secrets | Android Keystore: an AES-256-GCM key (non-exportable) encrypts the device token; ciphertext in a private DataStore file | EncryptedSharedPreferences is deprecated |
| Build | Gradle with version catalog android/gradle/libs.versions.toml, Gradle daemon on JDK 25 (pinned), bytecode target Java 17. With AGP 9, check runs only the debug unit-test variant. versionCode and versionName come from the Gradle properties kvitt.versionCode and kvitt.versionName, defaulting to 1 and dev (§10.6) |
Existing pin |
| Tests | JUnit, Compose screenshot tests with Roborazzi (1.76.0 at M2b) on Robolectric (4.17) with @Config(sdk = [36]) (D-010), Room tests (DAOs and migrations) on Robolectric with the framework SQLite driver, migration tests built from the exported schema JSON rather than room3-testing (D-042), Ktor MockEngine. Unit tests run on JDK 25 with --add-exports java.base/jdk.internal.access and --add-opens java.base/java.io for Robolectric, set in build-logic for every Android module (D-013). failOnNoDiscoveredTests is off only for modules without src/test (the shells) and on everywhere else (D-014) |
No mocking libraries (CLAUDE.md) |
| Typeface | The Material 3 default typeface; the app bundles no font file (D-020) | No licence step, no font resource |
Module graph (directory android/):
| Module | Kind | Contents | May depend on |
|---|---|---|---|
:core:model |
pure Kotlin/JVM | Domain types (§14.5.1), analytics types (§14.5.4, package se.hagstedt.kvitt.model.analytics), *State classes, repository interfaces (§14.2.4) |
kotlin, kotlinx |
:domain |
pure Kotlin/JVM | Split, balance, allocation, analytics functions (§14.5); test-vector tests | :core:model |
:core:design |
Android library | Theme, tokens, typography, shared components (money text, avatar, category chip, balance header, charts; the ledger rows for expenses and settlements used by S-5, S-12 and S-16; the expense editor fields used by S-7 and S-19, D-019), MoneyFormatter, DateFormatter, shared strings |
:core:model |
:core:fakes |
Android library, used only by tests, screenshot tests and debug previews | Scenario-driven fakes of every repository interface (they call :domain for computed values so fakes never disagree with the real rules) |
:core:model, :domain |
:data |
Android library | Room DB, DAOs, entities, Ktor API client and DTOs, sync engine, WorkManager workers, token store, repository implementations, APK updater | :core:model, :domain |
:adapter:fcm |
Android library | FirebaseMessagingService, token registration, maps messages to PushEvent (:core:model) |
:core:model |
:feature:onboarding |
Android library | Pairing, partner profile step, waiting for partner, device removed | :core:model, :core:design |
:feature:home |
Android library | Home (global balance, accounts), sync status, update banner | :core:model, :core:design |
:feature:account |
Android library | Account detail, account editor | :core:model, :core:design |
:feature:expense |
Android library | Expense editor, expense detail and timeline | :core:model, :core:design |
:feature:settle |
Android library | Settle up (global and per account), settlement detail and timeline | :core:model, :core:design |
:feature:analytics |
Android library | Analytics screen and drill-down list | :core:model, :core:design |
:feature:settings |
Android library | Settings hub, profile, categories, recurring templates, devices, invites, about | :core:model, :core:design |
:app |
Android application | MainActivity, navigation graph, Hilt wiring, deep links, notification channel and building, Application class |
everything |
Screenshot tests (D-010). roborazzi.test.verify=true in android/gradle.properties makes every unit-test run, and so check, compare renders with the committed goldens. An intended change is re-recorded with tools/gradle-serial.sh recordRoborazziDebug, and every changed PNG is opened before committing. AGP's Compose Preview Screenshot Testing plugin was still alpha when M2b chose.
Features never call :domain directly: repositories return computed state (balances, previews, proposals), implemented in :data with :domain. The architecture check (Gradle task in check, owned by milestone M2b) fails the build on any other dependency edge and on java.time, java.io or android.* imports in :core:model and :domain.
Feature convention (D-017). Binding for every feature module (worked example and the stateless overload used by previews in android/README.md):
- Each feature exposes one composable entry point per screen. The entry point takes a ViewModel provided with
hiltViewModel()and its navigation as plain lambdas (for exampleonOpenExpense: (ExpenseId) -> Unit,onBack: () -> Unit). - Features never reference other features or the
NavHost.:appowns theNavHostand connects the lambdas to routes. - ViewModels receive repositories through the
:core:modelinterfaces (§14.2.4).:appbinds the interfaces through Hilt to:data; bindings to:core:fakesexist only in test and debug-preview source sets and never reach a release build. - Shared UI that two features need is built in
:core:designby M6 (D-019); a feature never copies another feature's component. - Strings:
:appholds only the app name;:core:designholds the shared strings (including the notification copy and channel name of §13.3 and §14.6, which:appuses); each feature module holds its own. LintMissingTranslationis an error in every module. Keys of a feature module are prefixed by the feature name (home_,account_,expense_, ...); keys in:core:designnever start with a feature name. This rule wins over any other key convention (§13.4, §14.7).
Fakes. Every fake in :core:fakes has at least the empty scenario (D-018): the household is complete (two named users, no pictures), there are no accounts, the categories are exactly the ten defaults of AC-7.2, and there are no expenses, settlements, templates, rejections or pending changes. Feature agents add their own scenarios (§11.3).
6.3 Server
| Concern | Choice | Why |
|---|---|---|
| Language | Rust stable, edition 2024; the exact release pinned in server/rust-toolchain.toml (1.99.1 at M2a) and the same tag in the Dockerfile (§10.3) |
Owner's requirement; small RSS on a 1 GB Pi |
| HTTP | axum on tokio (multi-thread runtime, 2 worker threads) | Mature, small |
| DB | SQLite via sqlx, WAL mode, migrations embedded with sqlx::migrate! |
One file, easy backup, ample for two users |
| Serialization | serde, serde_json | Standard |
| OpenAPI | utoipa generates the document from code; contracts/openapi.yaml is the committed contract; a test asserts the generated document equals it (§14.3) |
One contract, checked by the gate |
| Logging | tracing, tracing-subscriber, JSON lines to stdout | Docker log rotation; no personal content (§8.7) |
| Time | chrono with chrono-tz for Europe/Stockholm (M2a confirmed; no other time crate) | DST-correct scheduling |
| Ids | uuid crate, v7 | Same format as phones |
| Images | image crate (decode JPEG/PNG/WebP, encode JPEG) | Pure Rust, no system libs |
| HTTP client (FCM) | reqwest with rustls (crypto provider aws-lc-rs, certificates checked by the platform verifier); needs a C toolchain at build time and CA roots in the image (§10.3) | No OpenSSL on the image |
| FCM auth | Service-account JWT (RS256, jsonwebtoken crate, header kid = the key file's private_key_id) exchanged at https://oauth2.googleapis.com/token for an access token with scope https://www.googleapis.com/auth/firebase.messaging, cached until 5 minutes before expiry |
HTTP v1 API needs OAuth2 |
| Scheduler | A tokio task inside kvitt-server (no extra crate) |
One process, simple |
Cargo workspace (directory server/):
| Crate | Kind | Contents |
|---|---|---|
kvitt-domain |
library, no I/O | Money types, split, balance, allocation, settlement validation (§14.5), next_occurrence_date and the day clamping of §9.5 (D-015), test-vector tests. Depends only on serde; serde_json is a dev-dependency of the vector tests. Ids are plain strings (§14.5.1), so no uuid |
kvitt-server |
binary kvitt-server |
Config, DB, migrations, HTTP API, auth, sync, audit, push, scheduler, pictures, APK, CLI subcommands (§10.4) |
kvitt-import |
binary kvitt-import |
Splitwise CSV importer (§10.8). Depends on kvitt-domain and reuses kvitt-server as a library for DB access and audit writing (kvitt-server exposes a lib.rs) |
Server seams (D-015). So that M3, M4 and M9 work in parallel on M2a's skeleton:
- Middleware in
router.rs(M2a): theX-Kvitt-Protocolcheck (426protocol_unsupported), theX-Kvitt-Server-Versionresponse header and the §8.6 body limits (1 MiB, 5 MiB forPUT /me/picture) are layers insrc/router.rs. On authenticated routes the protocol check runs before the token check, so a request with a bad protocol header and a bad token gets 426, not 401. M3's rate limit (§8.6, AC-2.12) is a layer defined insrc/api/rate_limit.rsand attached at one line inrouter.rsthat M2a marks with a comment; that line is the only one M3 edits inrouter.rs. - Background tasks: M2a creates
src/tasks/mod.rswith astartfunction thatservecalls once after migrations (initially doing nothing). M4 ownssrc/tasks/**and starts the scheduler, the push sender and thesync_mutationspruning from it. As M4 built it (D-039):tasks::startreturns theAppStatewith the real push queue installed, andservebuilds the router from that returned state. - Audit and push interfaces: M2a defines the traits
AuditWriter(insrc/audit/) andPushQueue(insrc/push/) with no-op implementations, and passes them through the application state (AppState.audit,AppState.push). M4 replaces the implementations (audit writer and diff of §14.4.7, FCM queue of §14.6); M3 and M9 only call the traits. Since M4 (D-039):AppState::newholdsSqlAuditWriterby default andNoopAuditWriterno longer exists;NoopPushQueuestays as the queue when push is disabled (§14.6), andtasks::startinstalls the boundedPushHubotherwise. As M2a built them (exact signatures inserver/README.md):AuditWriter::writetakes the caller's open connection inside itsBEGIN IMMEDIATEtransaction and oneAuditEvent(entity type and id, action, source, actor user and device,client_at,server_at, and the wire object before the change, absent for a create, and after it, as JSON), and returns the new audit entry's id or anAppError. The writer deriveschanges(§14.4.7) and takes the entry'sserver_versionfrom that same transaction.PushQueue::enqueuetakes onePushMessage(push type, one of the eight of §14.6; entity id or none; actor user id; count). It never blocks and never fails; callers call it after commit.
- Recurring generation: M2a implements
next_occurrence_dateand the day clamping inkvitt-domain(§14.5.2). M3 writes the one function that generates the due periods of one template (§9.5: candidate periods, one transaction per candidate, audit throughAuditWriter, push throughPushQueue) insrc/api/recurring.rs, used by E-28 and E-29; M4's scheduler calls that function for every template. M4 therefore lands after M3 (it may start in parallel and rebases on M3 before its gate).
6.4 Why these choices
- Phone computes balances and analytics. Offline is a hard requirement and the data set is small (six years of two-person history is a few thousand rows). Computing on the phone keeps the server dumb and the Pi idle.
- SQLite over Postgres. Postgres alone would use a large share of the Pi's 1 GB. SQLite in WAL mode handles two users trivially and backs up as one file.
- Global
server_versionsequence. One integer cursor per phone is the simplest correct incremental sync. SQLite's single writer, with every write in aBEGIN IMMEDIATEtransaction, makes versions commit in order, so a reader never sees a gap that later fills. - Last-write-wins on client time. Two people rarely edit the same expense concurrently; LWW with an audit trail lets them see and fix the rare collision. Clock-skew protection is in §14.4.
- Push carries ids only. Personal data does not pass through Google; the phone syncs and builds the text locally.
- No Play Store. Two users; a self-hosted APK with
PackageInstalleris enough. - Domain rules twice, one test suite. Kotlin and Rust implement the same pure functions and must pass the same JSON vectors, so phone and server agree to the öre.
7. API outline
Base URL: https://icehack.asuscomm.com:8443 (the value of KVITT_PUBLIC_URL). All paths below are relative to it. There is no path prefix and no API version in the path; the protocol version travels in a header (§14.3.1).
| # | Method | Path | Auth | Purpose |
|---|---|---|---|---|
| E-1 | GET | /health |
none | Liveness and DB check |
| E-2 | GET | /setup/status |
none | Whether the household has been bootstrapped |
| E-3 | POST | /invites/accept |
none | Pair a device with an invite code (all three invite kinds) |
| E-4 | GET | /i/{code} |
none | Invite landing page (HTML) |
| E-5 | GET | /apk/latest |
none | Latest APK metadata |
| E-6 | GET | /apk/files/{file_name} |
none | APK download |
| E-7 | GET | /me |
device | Current user, device and household users |
| E-8 | PATCH | /me |
device | Change own display name |
| E-9 | PUT | /me/picture |
device | Upload own picture |
| E-10 | DELETE | /me/picture |
device | Remove own picture |
| E-11 | GET | /users/{user_id}/picture |
device | Download a user's picture |
| E-12 | POST | /invites |
admin | Create a partner or device invite |
| E-13 | GET | /invites |
admin | List pending invites |
| E-14 | DELETE | /invites/{invite_id} |
admin | Cancel an invite |
| E-15 | GET | /devices |
device | List devices (admin: all; member: own) |
| E-16 | DELETE | /devices/{device_id} |
device | Revoke a device |
| E-17 | PUT | /devices/current/push-token |
device | Set or clear this device's FCM token |
| E-18 | POST | /accounts |
device | Create account |
| E-19 | PATCH | /accounts/{account_id} |
device | Rename, change default ratio |
| E-20 | POST | /accounts/{account_id}/archive |
device | Archive |
| E-21 | POST | /accounts/{account_id}/unarchive |
device | Unarchive |
| E-22 | DELETE | /accounts/{account_id} |
device | Soft delete when empty |
| E-23 | POST | /categories |
device | Create category |
| E-24 | PATCH | /categories/{category_id} |
device | Rename, icon, colour, order, reset name |
| E-25 | POST | /categories/{category_id}/archive |
device | Archive |
| E-26 | POST | /categories/{category_id}/unarchive |
device | Unarchive |
| E-27 | DELETE | /categories/{category_id} |
device | Soft delete when unused and not default |
| E-28 | POST | /recurring-templates |
device | Create template |
| E-29 | PATCH | /recurring-templates/{template_id} |
device | Edit, pause, resume |
| E-30 | DELETE | /recurring-templates/{template_id} |
device | Soft delete |
| E-31 | GET | /sync |
device | Pull changes since a cursor |
| E-32 | POST | /sync |
device | Push local expense and settlement changes |
| E-33 | POST | /settlements/proposal |
device | Server-side allocation proposal |
Reads of accounts, categories, templates, expenses, settlements and audit entries happen only through GET /sync. Every REST write that changes a synced row returns that row in its wire form (§14.3.3), with its new server_version, so the phone can store it immediately.
Request and response shapes, error codes and status codes: §14.3. The OpenAPI 3.1 file contracts/openapi.yaml is written by milestone M1 from §14.3, which is binding.
8. Auth & security
8.1 Threat model
Two trusted users. The server is on the public internet on port 8443. Threats in scope: random internet scanners, a stolen or lost phone, leaked invite links, a compromised backup location, accidental personal data in logs or push payloads. Out of scope: a malicious partner, an attacker with physical access to the Pi.
8.2 Device tokens
- On a successful invite accept the server generates 32 random bytes from the OS CSPRNG and returns the token
kvt_+ base64url without padding of those bytes (47 characters total). - The server stores only
token_hash= lowercase hex SHA-256 of the full token string. - Tokens do not expire. They stop working only on revocation.
- Every authenticated request sends
Authorization: Bearer <token>. The server hashes it, looks up a device with that hash andrevoked_at IS NULL, and loads the user. Missing or unknown token: 401unauthorized. Revoked: 401device_revoked. - The server updates
devices.last_seen_atanddevices.app_version_codeat most once per hour per device. That update setsapp_version_codeonly when the request carriesX-Kvitt-App-Versionand its value parses as an integer; otherwise the stored value is kept. - On the phone the token is encrypted with an Android Keystore AES-256-GCM key (alias
kvitt_token_key,setUserAuthenticationRequired(false), no export) and the ciphertext and IV are stored in DataStore fileauth.preferences_pb. The server base URL, user id and device id are stored next to it in plain text, together with the pairing status (D-043). - The token is never logged, never put in a URL, never included in a crash report or screenshot.
8.3 Invite codes
- 12 characters from the Crockford base32 alphabet
0123456789ABCDEFGHJKMNPQRSTVWXYZ(60 bits), generated from the CSPRNG. - Displayed as three groups of four separated by
-, as7K3M-QX9D-2HPA. URLs carry the 12 characters without hyphens. - Normalisation before hashing or sending: uppercase; remove
-and spaces; replaceOwith0andIandLwith1. A normalised code that is not exactly 12 alphabet characters isinvite_invalid. - Stored as
code_hash= lowercase hex SHA-256 of the normalised code. - Single-use, 72-hour expiry, cancellable. Rate limit in AC-2.12.
8.4 Authorization rules
| Action | admin | member |
|---|---|---|
| All expense, settlement, account, category, recurring template operations | yes | yes |
| Edit own display name and picture | yes | yes |
| Edit the other user's display name or picture | no | no |
| Create, list, cancel invites | yes | no (forbidden) |
| List devices | all | own only |
| Revoke a device | any except current | own except current |
8.5 Transport
- TLS terminates in Caddy on port 8443 with the Let's Encrypt certificate for
icehack.asuscomm.com(§10.5). TLS 1.2 minimum, Caddy defaults otherwise. - Release builds' network security config: cleartext disabled, system trust anchors only, unless the fallback of §10.5.3 is active, in which case the pinned Caddy root replaces the system anchors for
icehack.asuscomm.comonly. - Debug builds additionally allow cleartext to the hosts listed in AC-2.8.
- The server trusts
X-Forwarded-Foronly from the Caddy container (the request's peer address on the compose network) to get the client IP for rate limiting.
8.6 Rate limiting and input limits
- Unauthenticated endpoints: at most 60 requests per minute per client IP, then 429
rate_limitedwith aRetry-Afterheader in seconds. - Authenticated endpoints: at most 600 requests per minute per device.
- Keying (D-032): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A request answered 401 also counts against its client IP's unauthenticated limit, and a token that was never accepted is refused with
rate_limitedonce that IP is over its limit. All windows are fixed from the first counted request. The client IP is the peer address, or, when the peer is the trusted proxy (KVITT_TRUSTED_PROXY, re-resolved at most every 60 seconds), the last entry ofX-Forwarded-For. Per-IP keying needs the peer address:serveruns the router withinto_make_service_with_connect_info::<SocketAddr>()(added by M4 inbd6f10a, D-032 fulfilled). GET /healthfrom the container healthcheck counts against the loopback IP's limit; at the compose healthcheck interval this stays far below 60 per minute and is accepted.- Invite accept failures: AC-2.12.
- Request bodies: 1 MiB maximum, except
PUT /me/pictureat 5 MiB. Larger: 413payload_too_large. Both limits are layers inrouter.rs(§6.3). - JSON strings are validated for length after trimming leading and trailing whitespace, and are stored trimmed,
notesincluded (D-034). Control characters other than newline are refused withvalidation_failedinnotesonly; this is intended. Names and descriptions accept newlines and are not checked for control characters. - The rate limiter is in memory (resets on restart); no table.
8.7 Privacy and logging
- Server logs contain: timestamp, level, request method, route template (not the raw path with codes), status, latency, device id, error code. They never contain tokens, invite codes, display names, descriptions, notes, amounts or picture bytes.
- The phone never logs personal content, debug builds included (
CLAUDE.md). Log tags use the prefixKvitt. - FCM payloads carry ids and types only (§14.6).
- Backups on Google Drive are not encrypted in v1; they go to the owner's own Drive folder, reachable only by his Google account.
8.8 Phone hardening
android:allowBackup="false"andandroid:dataExtractionRulesexcluding everything, so neither Google backup nor device transfer copies the token or database.- Exported components: only
MainActivity(launcher and deep-link intent filters) and the FCM service as Firebase requires. - On
device_revokedthe app wipes local data as in AC-3.3.
8.9 Server hardening
- The server container runs as uid/gid
10001, with a read-only root filesystem,/dataas the only writable volume,/secretsread-only, no Linux capabilities,no-new-privileges. - Only Caddy publishes a port (8443). The server, Caddy's admin API and the sidecars are reachable only on the internal compose network.
- SQLite
PRAGMA foreign_keys = ON,journal_mode = WAL,synchronous = NORMAL,busy_timeout = 5000on every connection.
9. Edge cases & business rules
9.1 Splits
Split modes:
split_mode |
Meaning | Stored share_bp per user |
Stored share_ore per user |
|---|---|---|---|
default |
The account's default ratio at the time of saving | copied from the account | computed by the ratio rule |
ratio |
A custom ratio for this expense | as entered | computed by the ratio rule |
exact |
Exact amounts for this expense | null |
as entered |
full |
One user bears the whole amount | bearer 10000, other 0 | bearer = amount, other 0 |
Ratio rule (modes default, ratio, full; exactly two users P = payer, N = non-payer):
share_ore(N) = floor(amount_ore * share_bp(N) / 10000)
share_ore(P) = amount_ore - share_ore(N)
The non-payer's exact share is rounded down, so the payer absorbs the remainder: the payer's share is 0 or 1 öre more than their exact share. Intermediate products are computed in 64-bit integers (the maximum, 1 000 000 000 × 10000, fits). Examples (also test vectors):
| amount_ore | payer | bp payer / non-payer | share payer | share non-payer |
|---|---|---|---|---|
| 1001 | A | 5000 / 5000 | 501 | 500 |
| 1 | A | 5000 / 5000 | 1 | 0 |
| 100 | B | 4000 / 6000 | 40 | 60 |
| 999 | A | 3333 / 6667 | 333 | 666 |
| 999 | B | 6667 / 3333 | 667 | 332 |
| 50000 | A | 0 / 10000 | 0 | 50000 |
| 50000 | A | 10000 / 0 | 50000 | 0 |
Exact rule: both shares are given, each 0 or more, and they must sum exactly to the amount, otherwise exact_sum_mismatch.
Validity: exactly two share entries, one for each household user, no other user ids; bp each 0 to 10000 and summing to 10000.
Server check: for every pushed expense the server recomputes the shares from split_mode, the amount, the payer and the given bp (or exact öre) with kvitt-domain and rejects the mutation with reason validation_failed if the pushed share_ore values differ. For mode default the server does not compare the bp with the account's current default (the phone may have copied an older default while offline); it only checks they sum to 10000.
9.2 Effect line from the viewer's side (lists)
For viewer V and an expense with payer P:
| Case | Line |
|---|---|
| V = P and the other's share > 0 | "You lent <other's share>" |
| V ≠ P and V's share > 0 | "You borrowed <V's share>" |
| the non-payer's share = 0 | "Not involved" when V ≠ P; "Paid for yourself" when V = P |
9.3 Balances
For an account and user u, over non-deleted expenses and non-deleted settlements:
net(u) = Σ expenses where payer = u : amount_ore
− Σ expenses : share_ore(u)
+ Σ settlement allocations where from = u : allocation.amount_ore
− Σ settlement allocations where to = u : allocation.amount_ore
net(A) + net(B) = 0always.- The account balance shown to viewer V is
net(V): positive " owes you net(V)", negative "You owe −net(V)", zero "All settled up" (account screens say "Settled up"). - The global balance is the sum over all accounts, archived included, deleted excluded (a deleted account has no non-deleted rows by AC-6.5).
- Balances include pending local changes.
9.4 Settlement allocation proposal
Inputs: payer user id, amount (öre, > 0), and for every non-deleted account: id, created_at, archived flag, and the payer's current net in it (computed with §9.3, excluding the settlement being edited if any).
Algorithm (normative; §14.5 has the signature):
- Owing accounts = accounts where payer's net < 0. Sort by
created_atascending, then byidascending. remaining = amount. For each owing account in order whileremaining > 0:a = min(remaining, −net), emit allocation(account, a),remaining −= a.- If
remaining > 0(the amount exceeds the total debt) and at least one owing account exists, addremainingto the last emitted allocation and report that account as over-settled. - If no owing account exists: the target is the oldest non-archived account (same sort); if every account is archived, the oldest account. Emit one allocation of the whole amount to it and report it as over-settled.
- If there are no accounts: error
no_accounts.
Output: ordered allocations, excess_ore (the amount added in steps 3 or 4, 0 otherwise), and over_settled_account_ids.
Consequences, stated so nobody "fixes" them:
- A global settle-up of exactly the global debt can leave individual accounts non-zero when the payer is owed in some account and owes in another (allocations are never negative). The global balance is then zero. Users settle individual accounts with per-account settle-ups in either direction.
- The proposal is only a suggestion; the user may edit any allocation, add accounts or remove rows.
Over-settle warning for a user-edited allocation list: allocation (account, a) is warned when a > max(0, −net_payer(account)).
Settlement validity (phone before saving, server on push): amount within §9.6; from_user_id ≠ to_user_id, both household users; 1 to 50 allocations; each amount_ore ≥ 1; account ids distinct and referencing existing non-deleted accounts; allocations sum to the amount. Error tokens in §14.5.3.
9.5 Recurring generation
- The scheduler runs once at server start and then daily at 06:00 Europe/Stockholm (DST-aware: always local 06:00). It reads the injected clock (
AppState.clock) at least every 30 seconds rather than sleeping until the next run: after a forward clock jump past one or more runs it runs the missed job once; after a backward jump of more than a day it plans the next run again from the new time. The 04:00 pruning loop (§14.1) follows the same rules. - For each template with
deleted_at IS NULLandpaused = 0, the candidate periods are every monthpwithstart_period ≤ p,p ≤ end_period(if set),p > last_generated_period(if set), and whose occurrence instant (the clamped date at 06:00 Europe/Stockholm) is at or before now. - For each candidate in ascending order the server, in one transaction: creates the expense (server-generated UUIDv7 id;
expense_date= clamped date; fields copied from the template;created_by_user_idandupdated_by_user_id= the template'screated_by_user_id;updated_by_device_idnull;created_at=updated_at= server now;recurring_template_idandrecurring_periodset), writes its splits (modedefaultresolves the account's current default ratio at generation time; other modes use the template's splits, withratio/fullrecomputed by the ratio rule for the template amount), writes an audit entry (create,source=recurring), sets the template'slast_generated_periodandupdated_at(server now), bumping itsserver_version, then enqueues the push (AC-9.6). - The unique index on
(recurring_template_id, recurring_period)makes generation idempotent; a conflict means the period was already generated and is skipped silently. - Pausing stops generation. Resuming does not backfill periods whose occurrence passed while paused: on resume the server sets
last_generated_periodto the latest period whose occurrence instant is before now, if that is later than the current value and at or afterstart_period. Resuming a template that has not started yet therefore keeps itsstart_period(D-033). - Changing
day_of_monthorstart_periodnever regenerates past periods. next_occurrence_date(in the wire form, computed withkvitt-domain::next_occurrence_date, §14.5.2) is the clamped date of the first candidate period afterlast_generated_periodthat is ≥start_periodand ≤end_period;nullwhen paused, deleted or pastend_period.- A template's account may be archived (generation continues). Account deletion is blocked while a non-deleted template references it (AC-6.5).
9.6 Limits and validation
All string lengths are counted in Unicode code points after trimming. One table, used by phone validation and server validation alike:
| Field | Rule | Error (REST) / reason (sync) |
|---|---|---|
amount_ore (expense, settlement, template) |
integer 1 to 1 000 000 000 (10 000 000,00 kr) | validation_failed |
allocation amount_ore |
integer ≥ 1 | validation_failed |
| allocations per settlement | 1 to 50 | validation_failed |
share_bp |
integer 0 to 10000; the two sum to 10000 | validation_failed |
share_ore |
integer ≥ 0; the two sum to the amount | validation_failed |
expense description, template description |
1 to 100 code points | validation_failed |
expense notes, template notes |
0 to 1000 code points; newlines allowed | validation_failed |
settlement note |
0 to 200 code points | validation_failed |
expense_date, settlement_date |
valid date, from 2000-01-01 to server today (Europe/Stockholm) + 365 days |
validation_failed |
account name |
1 to 60 code points; unique (AC-6.2) | validation_failed, name_taken |
category name |
1 to 40 code points, or null for a default category |
validation_failed |
user display_name |
1 to 40 code points | validation_failed |
device name |
1 to 60 code points | validation_failed |
day_of_month |
integer 1 to 31 | validation_failed |
start_period |
YYYY-MM, not earlier than 12 months before the current month |
validation_failed |
end_period |
null or YYYY-MM ≥ start_period |
validation_failed |
updated_at in a push |
≤ server now + 10 minutes | clock_skew |
mutations per POST /sync |
1 to 100; 0 is also batch_too_large (D-036) |
batch_too_large |
GET /sync limit |
1 to 1000, default 500 | invalid_request |
GET /sync since |
required, integer ≥ 0 | invalid_request |
| picture upload | ≤ 5 MiB, ≤ 8000 × 8000 px, JPEG/PNG/WebP | payload_too_large, image_invalid, unsupported_media_type |
Strings are stored as validated, that is trimmed (§8.6, D-034).
On the phone, a rule of this table that has no domain error token of §14.5.3 (the length limits, the date limits, day_of_month, the periods) is reported as FieldError.error = validation_failed (§14.2.4), the same token as the REST code; a missing required value is required.
The phone input field for amounts accepts digits, one decimal separator (, or .) and at most two decimals, without sign, and converts to öre exactly (string parsing, never floating point).
9.7 Icon and colour tokens
Category icon tokens are Material Symbols names. The allowed set (exhaustive, v1):
shopping_cart, restaurant, local_cafe, home, bolt, water_drop, wifi, directions_car, train, local_gas_station, flight, hotel, luggage, movie, music_note, sports_esports, fitness_center, medical_services, medication, shopping_bag, checkroom, pets, child_care, school, card_giftcard, celebration, build, chair, subscriptions, category.
Colour tokens (exhaustive, v1) with provisional values; the UX sprint may change the hex values and must keep the token names:
| Token | Light | Dark |
|---|---|---|
green |
#2E7D32 |
#81C784 |
orange |
#E65100 |
#FFB74D |
brown |
#6D4C41 |
#BCAAA4 |
yellow |
#F9A825 |
#FFF176 |
blue |
#1565C0 |
#90CAF9 |
teal |
#00796B |
#80CBC4 |
purple |
#6A1B9A |
#CE93D8 |
red |
#C62828 |
#EF9A9A |
pink |
#AD1457 |
#F48FB1 |
grey |
#546E7A |
#B0BEC5 |
indigo |
#283593 |
#9FA8DA |
lime |
#827717 |
#DCE775 |
The server validates icon and colour against these lists (validation_failed).
9.8 Formatting
- Money, Swedish locale:
1 234,50 kr(U+00A0 no-break space as the thousands separator and beforekr; comma decimal; always two decimals). - Money, any other locale:
SEK 1,234.50(U+00A0 afterSEK; comma thousands; point decimal; always two decimals). - Money is never shown with a minus sign; direction is in words (§9.2, §9.3).
- Dates in lists: Swedish
3 okt., English3 Oct; with year when not the current year (3 okt. 2025,3 Oct 2025). Detail views: Swedishlördag 3 oktober 2026, EnglishSaturday, 3 October 2026. Timeline times: 24-hour14:02in both languages. - Relative "last synced": "just now" (< 1 minute), "N min ago" (< 60 minutes), "N h ago" (< 24 hours), otherwise the list date and time. Swedish: "nyss", "för N min sedan", "för N h sedan".
- Formatting lives in
:core:design(MoneyFormatter,DateFormatter), never in:domain.
9.9 Online-only actions
When the phone has no validated network (Android NET_CAPABILITY_VALIDATED) or the last request failed with a network error less than 30 seconds ago, these actions are disabled with "Needs connection": create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update. A REST call that fails with a network error shows "Could not reach the server" and changes nothing locally.
9.10 Other edge cases
| Situation | Rule |
|---|---|
| Both users edit the same expense offline | Last updated_at wins; the loser's phone receives stale with the winner's row, overwrites locally and lists it under "Changes not synced" (§14.4.6). Both edits are in the audit log only if both were accepted, so only the winning edit is logged. |
| One user deletes, the other edits the same expense offline | Delete wins regardless of arrival order and timestamps: a pushed delete is always applied, and any non-delete write to a tombstoned row is rejected deleted (§14.4.4). |
| An expense is created offline in an account that was deleted meanwhile | Rejected reference_not_found; the row is removed locally and listed under "Changes not synced". Cannot happen for archived accounts (writes to archived accounts are accepted). |
| An expense uses a category that was archived meanwhile | Accepted. |
| Phone clock far in the future | Pushes rejected clock_skew; the app shows "Your phone's clock is wrong" on the sync status screen and keeps the changes pending. |
| Phone clock behind | Its edits may lose LWW against older real edits. Accepted risk. |
| Server restored from an older backup | The server's sync_epoch changes (§10.9); phones detect it and re-push everything they hold (§14.4.8), so changes made after the backup are recovered from the phones. |
| Partner never joins | Admin stays in the waiting state (AC-5.1). |
| Admin loses phone | kvitt-server pair-device --user <admin id> on the Pi (AC-1.6); then revoke the lost device from the new phone. |
| Both users' devices revoked | Same recovery as above for each user. |
| An account is renamed to a name used by a deleted account | Allowed (uniqueness ignores deleted accounts). |
| Recurring template's category deleted | Cannot happen: category deletion is blocked while a template uses it. |
Expense amount edited in mode exact |
The editor keeps the typed user's share and recomputes the other; if the typed share now exceeds the amount, saving is blocked (AC-8.7). |
| Settlement edited so that an allocation's account was deleted meanwhile | Cannot happen: an account with allocations cannot be deleted. |
| Same user on two devices edits offline | Same as two users: LWW per row. |
| Time zones | Dates (expense_date, settlement_date) are calendar dates chosen by the user; analytics never convert them. Only "today" defaults use the phone's zone; server validation and scheduling use Europe/Stockholm. |
| A day with a DST change | Scheduling uses local 06:00, which exists on every day in Europe/Stockholm. |
10. Deployment & ops
All infrastructure files live in infra/ (ownership §14.7). Secrets live in infra/secrets/ on the Mac (git-ignored) and in /opt/kvitt/secrets/ on the Pi; neither is ever committed.
Development first (D-001). During development the server runs locally on the owner's M4 Mac (Docker compose from infra/dev/compose.yaml, or plain cargo run in server/), and debug builds pair against it over the home Wi-Fi (debug builds accept http:// to private LAN addresses, AC-2.8). The development stack (built by M2c-dev, landed c885aba, D-022) has the server service and the backup job runnable on demand without the Drive upload; it has no Caddy and no TLS (plain http://, which debug builds accept on private LAN addresses). Its data is a bind-mounted folder on the Mac (infra/dev/data/, git-ignored, and infra/dev/backups/ for local snapshots); it mounts the whole infra/secrets/ folder read-only at /secrets instead of a single key file (a missing fcm-service-account.json means push is disabled, §10.4); its env file is infra/dev/.env.example; its server healthcheck is kvitt-server healthcheck; its backup runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env file stays infra/env.example, written by M2c-pi. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10.
10.1 Hosts and network
| Host | Role | Facts |
|---|---|---|
| Kvitt Pi | Runs the compose stack | Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin, static LAN address. LAN address and SSH user: TODO(spec) OQ-3 |
| Home Assistant Pi | Owns ports 80/443 and the Let's Encrypt certificate (certbot) for icehack.asuscomm.com |
Reached by cert-sync over SSH. Address, SSH user and certificate paths: TODO(spec) OQ-3 |
| Owner's M4 Mac | Builds images and APKs; runs the development server (D-001) | Apple Silicon, so linux/arm64 builds run natively in Docker |
| Router (ASUS) | icehack.asuscomm.com DDNS |
Forwards external TCP 8443 to Kvitt Pi TCP 8443 (HUMAN step) |
| Container registry | Holds Kvitt images | A Gitea instance on another Pi at http://gitea.lan: plain HTTP, reachable only on the home Wi-Fi (D-001). If Gitea requires credentials for push or pull, the owner supplies them during M2c-pi (HUMAN) and they live in infra/secrets/ |
The Pi never builds anything. It only pulls images and runs them.
10.2 Compose stack
File infra/compose.yaml, deployed to /opt/kvitt/compose.yaml on the Pi with /opt/kvitt/.env (from infra/env.example). Services:
| Service | Image | Ports / volumes | Memory limit | Healthcheck |
|---|---|---|---|---|
caddy |
official caddy image, pinned by version and digest |
publishes 8443:8443; ./Caddyfile:/etc/caddy/Caddyfile:ro; certs:/certs:ro; caddy_data:/data |
64 MiB | wget -qO- http://localhost:2019/config/ >/dev/null |
server |
${KVITT_REGISTRY}/kvitt/server:${KVITT_TAG} |
data:/data; /opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro; /opt/kvitt/import:/import:ro (empty except during the one-off import, §10.8, D-025); no published port (listens on 8080 internally) |
160 MiB (budget: RSS under 100 MB in steady state) | GET http://localhost:8080/health via the binary's own kvitt-server healthcheck subcommand (no curl in the image) |
cert-sync |
${KVITT_REGISTRY}/kvitt/cert-sync:${KVITT_TAG} |
certs:/certs; ./Caddyfile:/etc/caddy/Caddyfile:ro; /opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro; /opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro |
32 MiB | both cert files exist and are non-empty |
backup |
${KVITT_REGISTRY}/kvitt/backup:${KVITT_TAG} |
data:/data; backups:/backups; /opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro |
96 MiB | last successful run marker newer than 26 hours, or no run yet since start |
- Named volumes:
data(SQLite, pictures, APKs),certs,caddy_data,backups. caddydepends_oncert-syncwithcondition: service_healthyandserverwithcondition: service_started.- Every service:
restart: unless-stopped, logging driverjson-filewithmax-size: 10m,max-file: 3,TZ=Europe/Stockholm. - One internal bridge network
kvitt. - The
cert-syncandbackupcontainers run as uid 1000. Their secret files on the Pi (/opt/kvitt/secrets/ha_ssh_key,ha_known_hosts,rclone.conf) have mode 0600 and are owned by uid 1000 (D-023). The FCM key keeps the mode of §10.7. The development stack runsbackupas uid 10001 instead, because opening the WAL database needs access to the files the server owns; M2c-pi reconciles the two (uid 1000 with access to thedatavolume, or uid 10001 with the secrets owned to match) and records the choice ininfra/README.md.
.env keys (exhaustive): KVITT_REGISTRY, KVITT_TAG, HA_HOST, HA_PORT, HA_USER, HA_CERT_DIR, RCLONE_REMOTE (default gdrive:), RCLONE_FOLDER_ID.
10.3 Images, registry and build
- Script
infra/build-images.sh [tag]on the Mac:docker buildx build --platform linux/arm64 --pushfor the three Kvitt images, tag defaults to the short git SHA ofHEAD, also tagslatest. Refuses to run with a dirty working tree. serverimage: multi-stage. Stage 1rust:<version>-bookworm, where<version>is the exact Rust release pinned inserver/rust-toolchain.toml(the current stable when M2a pins it; the two always change together), buildskvitt-serverandkvitt-importin release mode with--lockedforaarch64-unknown-linux-gnu(native on the M4; notarget-cpu=native, the Pi 3 is a Cortex-A53). Stage 2gcr.io/distroless/cc-debian12:nonroot(arm64) pinned by digest, with the two binaries in/usr/local/bin/, user 10001, entrypointkvitt-server, commandserve. The Dockerfile selects the Rust target fromTARGETARCH: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; that the shipped Pi image has a working C stage and CA roots, and that an FCM send succeeds from it, is confirmed by M2c-pi or M10.cert-syncimage:alpinewithopenssh-client,curland a shell scriptinfra/cert-sync/sync.shrun bycrond.backupimage:alpinewithsqlite,gzip,tar,rcloneandinfra/backup/backup.shrun bycrond.- Script
infra/deploy.sh <tag>on the Mac: copiescompose.yamlandCaddyfileto the Pi over SSH, setsKVITT_TAGin/opt/kvitt/.env, runsdocker compose pull && docker compose up -d, waits forserverto be healthy, printsGET /health. - The Pi's Docker daemon must trust
gitea.lanas an insecure (plain HTTP) registry (insecure-registries). Adding it is its own HUMAN step (sudo on the Pi); M2c-pi writes the exact step. - Later, images are built by Gitea Actions on a Pi from an example workflow the owner provides (
TODO(spec)until provided, D-001). The workflow file lives in.gitea/workflows/at the repository root, owned by M2c-pi (D-023). Until theninfra/build-images.shon the Mac pushes togitea.lan.
10.4 Server configuration and CLI
Environment variables (exhaustive):
| Variable | Default | Meaning |
|---|---|---|
KVITT_DATA_DIR |
/data |
Holds kvitt.sqlite, pictures/, apk/ |
KVITT_BIND |
0.0.0.0:8080 |
Listen address |
KVITT_PUBLIC_URL |
https://icehack.asuscomm.com:8443 |
Used in invite links and the landing page |
KVITT_FCM_CREDENTIALS |
/secrets/fcm-service-account.json |
Service-account key; if the file is missing, push is disabled and a warning is logged at start |
KVITT_FCM_PROJECT_ID |
kvitt-1337 |
FCM project |
KVITT_TRUSTED_PROXY |
caddy |
Host name whose resolved address may set X-Forwarded-For. The server must start when the name does not resolve (as on the development stack, which has no Caddy); then no peer is trusted and the peer address is the client IP (D-022). The name is re-resolved at most every 60 seconds (D-032) |
RUST_LOG |
info |
tracing filter |
The time zone Europe/Stockholm is a constant in code, not configuration.
Files under KVITT_DATA_DIR:
kvitt.sqlite(+-wal,-shm)pictures/<user_id>/<picture_version>-512.jpgand-128.jpg; older versions are deleted after a successful upload. E-10 deletes the JPEGs and leaves an empty marker file<picture_version>-removedinpictures/<user_id>/; the next upload uses the highest version on disk (JPEG or marker) plus one, so a cache keyed by (user_id,picture_version) never shows a removed picture. The first upload is version 1 (D-030).apk/latest.jsonandapk/kvitt-<version_code>.apk
kvitt-server subcommands (exhaustive):
| Command | Effect | Exit codes |
|---|---|---|
serve |
Run migrations, start scheduler, push sender and HTTP server | 0 on clean shutdown (SIGTERM drains in-flight requests up to 10 s) |
bootstrap |
FR-1 | 0 ok, 2 already initialized |
pair-device --user <user_id> |
AC-1.6 | 0 ok, 3 unknown user |
rotate-epoch |
Sets a new random sync_epoch and prints it on stdout (§10.9) |
0 ok, 1 database error |
healthcheck |
Calls http://127.0.0.1:<port of KVITT_BIND>/health (8080 by default), exit 0 if 200; used by the compose healthchecks of both stacks (no curl in the image) |
0 or 1 |
migrate |
Run migrations only | 0 |
Run them on the Pi with docker compose exec server kvitt-server <command> (or docker compose run --rm server <command> when server is stopped). Outside Docker, from the workspace root server/: cargo run -p kvitt-server -- <command> (the -p is needed because the workspace has two binaries), with KVITT_DATA_DIR set (server/README.md).
10.5 TLS
10.5.1 Certificate sync
- The Home Assistant Pi renews the Let's Encrypt certificate for
icehack.asuscomm.com(its own certbot; Kvitt does not touch it). cert-syncrunssync.shat container start and daily at 04:00 Europe/Stockholm. It connects to${HA_USER}@${HA_HOST}:${HA_PORT}with the key/secrets/ha_ssh_keyandStrictHostKeyChecking=yesagainst/secrets/known_hosts, and copies${HA_CERT_DIR}/fullchain.pemand${HA_CERT_DIR}/privkey.peminto a temp directory.- The key is dedicated to Kvitt and, where the Home Assistant SSH setup allows
authorized_keysoptions, restricted withrestrict,command="tar -C <cert dir> -cf - fullchain.pem privkey.pem"(the script then reads a tar stream instead of usingscp). The infra plan records which variant the Home Assistant SSH add-on supports. - If both files parse as PEM, the certificate's not-after is in the future, and the SHA-256 of either differs from
/certs, the script moves them into/certs(mode 0640, group readable by Caddy) and reloads Caddy:curl -fsS -X POST http://caddy:2019/load -H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @/etc/caddy/Caddyfile. - Any failure logs one line and keeps the existing certificate. The healthcheck stays green while valid files exist.
10.5.2 Caddyfile
{
admin 0.0.0.0:2019
auto_https off
}
icehack.asuscomm.com:8443 {
tls /certs/fullchain.pem /certs/privkey.pem
request_body {
max_size 6MB
}
reverse_proxy server:8080
log {
output stdout
format json
}
}
Caddy logs must not include the Authorization header (Caddy redacts it by default; the infra plan verifies).
10.5.3 Fallback: Caddy internal CA
Used only if the certificate sync cannot be made to work. Documented in infra/README.md, not deployed by default.
- Caddyfile site block uses
tls internalinstead of the file pair. - The Caddy root certificate (
/data/caddy/pki/authorities/local/root.crtincaddy_data) is copied once toandroid/app/src/release/res/raw/kvitt_root_ca.crt. This file is a public certificate and is committed. - The release network security config gets a
domain-configforicehack.asuscomm.comwhose trust anchors are only@raw/kvitt_root_ca. Switching is a build propertykvitt.tls.pinnedRoot=trueinandroid/gradle.properties. - Caddy's internal root is valid 10 years; intermediates renew automatically.
10.6 APK hosting and release
-
version_code=git rev-list --count HEADon the commit being released;version_name=YYYY.MM.DD-<short sha>of that commit. Both are passed to Gradle as propertieskvitt.versionCodeandkvitt.versionNameby the release script; debug builds use1anddev. -
The release keystore is created once by the owner (HUMAN, §11, D-024) on the Mac, in a directory outside the repository, with exactly:
keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000The file is never inside the repository or a worktree.
android/local.propertiesholds its absolute path and secrets in the keyskvitt.release.storeFile,kvitt.release.storePassword,kvitt.release.keyAlias(kvitt),kvitt.release.keyPassword. Losing it means the phones must uninstall and reinstall (data comes back by sync). The owner keeps a copy in their password manager. -
infra/publish-apk.shon the Mac: builds:app:assembleReleasethroughtools/gradle-serial.sh, computes SHA-256 and size, copies the APK to the Pi'sdatavolume asapk/kvitt-<version_code>.apk, writesapk/latest.jsonatomically (write temp, rename):
{
"version_code": 412,
"version_name": "2026.11.02-1a2b3c4",
"file_name": "kvitt-412.apk",
"size_bytes": 18234567,
"sha256": "9f2c…64 hex chars…",
"released_at": "2026-11-02T19:40:00.000Z"
}
- The server reads
latest.jsonon eachGET /apk/latest(no cache) and servesGET /apk/files/{file_name}only for afile_namematching^kvitt-[0-9]+\.apk$that exists, withContent-Type: application/vnd.android.package-archive. - Older APKs stay on the Pi; the publish script keeps the newest 5 and deletes older ones.
10.7 Firebase setup (M2c-dev, with a HUMAN sign-in)
No new command-line tool is installed for this (D-022): gcloud exists on the Mac, the Firebase CLI is not used, and the Firebase project and Android apps are created through the Firebase Management REST API (https://firebase.googleapis.com/v1beta1/) with the access token of gcloud auth print-access-token. The agent confirms the exact requests from Google's official API reference at implementation time.
- HUMAN:
gcloud auth loginon the Mac with the owner's Google account. gcloud config set project kvitt-1337;gcloud services enable fcm.googleapis.com firebase.googleapis.com.- Add Firebase to the project (
projects.addFirebase), skipped if it is already a Firebase project. - Create two Android apps (
projects.androidApps.create): display name "Kvitt", packagese.hagstedt.kvitt; display name "Kvitt debug", packagese.hagstedt.kvitt.debug. - Fetch each app's config (
projects.androidApps.getConfig) and merge them into onegoogle-services.jsonholding both clients, written toinfra/secrets/google-services.jsonin the main checkout (git-ignored). M8 copies it intoandroid/app/google-services.json(git-ignored) withinfra/dev/copy-google-services.sh <main checkout>, written by M2c-dev. gcloud iam service-accounts create kvitt-fcm --display-name "Kvitt FCM sender"; grantroles/firebasecloudmessaging.adminon the project;gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com.- Copy the key to
/opt/kvitt/secrets/fcm-service-account.jsonon the Pi (mode 0440, group 10001). This step belongs to M2c-pi. - If an organisation policy blocks service-account key creation, stop and report; do not change org policies.
The agent may write these git-ignored files under infra/secrets/ in the main checkout and act on the Google Cloud project kvitt-1337 for steps 2 to 6 (D-022); nothing else outside the repository.
State after M2c-dev (c885aba): steps 1 to 6 are done. Firebase is added to project kvitt-1337; Android apps exist for se.hagstedt.kvitt and se.hagstedt.kvitt.debug; the APIs firebase.googleapis.com, fcm.googleapis.com, cloudresourcemanager.googleapis.com and iam.googleapis.com are enabled; the service account is kvitt-fcm@kvitt-1337.iam.gserviceaccount.com with role roles/firebasecloudmessaging.admin (a role named roles/firebasemessaging.admin does not exist); infra/secrets/fcm-service-account.json and infra/secrets/google-services.json (both clients) exist in the main checkout. infra/README.md documents the REST calls for repeating or inspecting the setup. Step 7 remains for M2c-pi.
The FCM free tier is sufficient; no billing account is required.
10.8 Splitwise import
Command (run on the Pi with the server stopped, or on the Mac against a copy of the database):
kvitt-import --db /data/kvitt.sqlite --file <csv> --account "<Kvitt account name>" \
--user "<Splitwise name of the owner>=<owner user id or display name>" \
--user "<Splitwise name of the partner>=<partner user id or display name>" \
[--fx <CUR>=<SEK per unit>]... [--dry-run]
- One CSV file per Splitwise group (Splitwise's group "Export as spreadsheet"). The account is created with default 50/50 if no non-deleted account with that name exists; otherwise rows go into the existing one, and the existing account's stored name (not the spelling given to
--account) is the account name used in the import key (D-046). --user(exactly two, distinct Splitwise names, one per household user) maps a member column to a Kvitt user given as a user id or as a display name that exactly one user has; anything else is a usage error (D-046).- The importer never creates or migrates a database:
--dbmust name an existing file whosemeta.schema_versionis1, else it exits 1 (runkvitt-server migratefirst) (D-046). - The whole run is one
BEGIN IMMEDIATEtransaction: find or create the account, skip rows whoseimport_keyexists, validate and insert the rest with the server's own push checks, write the audit entries, then reconcile (below). It commits only when every row is valid, the reconciliation is accepted and--dry-runis absent; otherwise it rolls back and nothing is written. A dry run runs the same transaction and rolls it back (D-046). - Exit status (D-046):
0imported (or, with--dry-run, would import);1database or file error;2usage (bad arguments,--usernot resolving to the two household users);3the file cannot be imported (header, invalid rows, non-SEK rows without--fx);4reconciliation mismatch or missing totals row. Nothing is written unless the status is0and--dry-runis absent. - Header (confirmed against both real exports, D-003; the exact header lines are recorded in PLAN-M9, not here): Splitwise localises the export, so two variants exist and the importer must accept both. English:
Date,Description,Category,Cost,Currency; Swedish:Datum,Beskrivning,Kategori,Kostnad,Valuta. Then one column per group member, named by the member's full Splitwise name, holding that member's net effect for the row (positive = paid more than their share). The member columns appear in different orders in different files, so the importer reads member names from the header and matches them to the--userarguments; it never relies on column positions. Any other header fails the import with a clear message, writing nothing. - Fields containing commas are double-quoted (CSV quoting). The file ends with an empty line followed by a row whose
DescriptionisTotal balance(English) orTotalsumma(Swedish), the totals row, holding each member's balance; it creates no row and is used only for the reconciliation. Blank rows are ignored. - Reconciliation (D-045): after the rows are written, inside the transaction, each user's balance of the whole account (
net(u), §9.3, over every non-deleted row of the account, not only the imported ones) must equal the sum of that member's values in the file's totals rows. One totals row is allowed per currency; a non-SEK totals row is converted with the--fxrate like any other value. A mismatch, or a file without a totals row, rolls the run back and exits4; a dry run reports the same. Importing into an account that already holds other rows therefore rolls back, since those rows enter the comparison. When any--fxconversion took place, per-value rounding can make an exact match impossible, so a mismatch is reported in the summary but accepted. A re-run of an already imported file skips every row and reconciles again on the same balance. DateisYYYY-MM-DD;Costand member columns are decimal numbers with.as separator, converted to öre exactly by string parsing.- For each expense row with members A and B and cost C (öre): the payer is the member with positive net. If
net_A > 0: payer A,share_B = net_A,share_A = C − net_A, modeexact. Symmetric for B. If both nets are 0: payer is the owner, modefullon the owner (the owner pays and bears the whole cost, D-046). Ifnet_A + net_B ≠ 0or a resulting share is negative, the row is reported as invalid and the import aborts (nothing written). - Rows whose
CategoryisPayment(English) orBetalning(Swedish) become settlements:from= the member with positive net,to= the other, amount = that net, one allocation to the account,note= the Splitwise description truncated to 200 code points (D-046). - Rows whose
Currencyis notSEKabort the import unless a--fx <CUR>=<rate>is given for that currency; then every amount is converted withround_half_up(amount_ore × rate)per value, and for expenses the exact shares are recomputed asshare_payer = C' − share_nonpayer'. import_key= lowercase hex SHA-256 ofsplitwise|v1|<account name>|<Date>|<Description>|<Category>|<Cost>|<Currency>|<member nets in header order, joined with |>|<n>where<n>is the 0-based occurrence index of an identical line earlier in the same file. Rows whose key exists are skipped. Idempotency uses the existingimport_keycolumns ofexpensesandsettlements(§14.1); no new column or table (D-046).- Every created row:
created_by_user_id=updated_by_user_id= the owner;updated_by_device_idnull;created_at=updated_at= the import time; one audit entrycreatewithsource=import; newserver_versions from the global sequence; no pushes. - Description is truncated to 100 code points; the full original goes into
noteswhen truncated. An empty description, or one over 1000 code points (thenoteslimit, §9.6), makes the row invalid (exit3, nothing written) (D-046). - Category mapping (case-insensitive exact match on the
Categorycolumn, English and Swedish names, D-003). The importer commits this table as the data fileserver/crates/kvitt-import/data/splitwise_categories.csv(columnssplitwise_name,language,default_key) and reads it from there; it is the only mapping. The Swedish names are those observed in the real Swedish export, each placed with its English Splitwise equivalent (D-044 added the nine found unmapped in the real run):
| Splitwise category (English) | Splitwise category (Swedish) | Kvitt default_key |
|---|---|---|
| Groceries, Food and drink - Other, Liquor | Livsmedel, Mat och dryck - Övrigt, Alkohol | groceries |
| Dining out | Restaurangbesök | restaurants |
| Electronics, Furniture, Household supplies, Maintenance, Mortgage, Rent, Services, Pets, Home - Other | Elektronik, Möbler, Förbrukningsvaror, Skötsel/underhåll, Avbetalning/Amortering, Hyra, Tjänster, Hem - Övrigt | home |
| Electricity, Heat/gas, Water, Trash, TV/Phone/Internet, Cleaning, Utilities - Other | TV/telefon/internet, Städning, Verktyg - Övrigt | utilities |
| Bicycle, Bus/train, Car, Gas/fuel, Parking, Taxi, Transportation - Other | Cykel, Buss/tåg, Bil, Bensin/bränsle, Parkering, Taxi, Transport - Övrigt | transport |
| Hotel, Plane | Hotell, Flyg | travel |
| Games, Movies, Music, Sports, Entertainment - Other | Spel, Musik, Filmer, Sport, Underhållning - Övrigt | entertainment |
| Medical expenses | Sjukvård/medicin | health |
| Clothing, Gifts | Kläder, Presenter | shopping |
| General, Childcare, Education, Insurance, Taxes, Life - Other, Other | Allmänt, Barnomsorg, Utbildning, Försäkringar, Skatter, Livet - Övrigt | other |
| any other value | any other value | other, counted as unmapped |
- The summary lists each unmapped category name with its row count, and the total count of unmapped rows as a warning.
Production run (M11, D-025), in this order:
- Take a backup first:
docker compose exec backup /backup.sh, and confirm both files on Drive (rclone lsf). - Resolve the two production user ids by display name from the database (a read-only
SELECT id, role, display_name FROM usersthrough thebackupcontainer'ssqlite3); the owner is theadminuser. The ids go into the--userarguments (an exact, unique display name is also accepted, D-046). - Copy the CSV files from the Mac to the Pi into
/opt/kvitt/import/, which is bind-mounted read-only intoserveras/import(§10.2). docker compose stop server; for each file rundocker compose run --rm --entrypoint kvitt-import server --db /data/kvitt.sqlite --file /import/<file> --account "<name>" --user "…" --user "…", first with--dry-run, then without; each must exit0with the reconciliationmatch(D-045); the target accounts must not exist yet or be empty;docker compose start server; check/health. (--entrypointis needed because the image's entrypoint iskvitt-server, §10.3.)- Delete the CSV files from
/opt/kvitt/import/.
10.9 Backups and restore
Backup (infra/backup/backup.sh, daily 03:30 Europe/Stockholm, also runnable as docker compose exec backup /backup.sh):
sqlite3 /data/kvitt.sqlite ".backup /backups/staging/kvitt.sqlite"(online, consistent).sqlite3 /backups/staging/kvitt.sqlite "PRAGMA integrity_check"must printok, else abort with a log line.gzip -9to/backups/kvitt-<UTC yyyyMMddTHHmmssZ>.sqlite.gz;tar -czf /backups/pictures-<same stamp>.tar.gz -C /data pictures.rclone copy /backups/<both files> "${RCLONE_REMOTE}" --drive-root-folder-id "${RCLONE_FOLDER_ID}" --immutable(copy only; neversync, never delete, never overwrite).- On success write
/backups/last-successwith the stamp. The local files are deleted from the Pi'sbackupsvolume as soon as both are confirmed on Drive (rclone lsf) (D-002); nothing on Drive is ever deleted. WhenRCLONE_FOLDER_IDis empty (the development stack, D-001), step 4 is skipped with a log line and the local files are kept.
rclone setup (HUMAN, once): on the owner's laptop run rclone authorize "drive" and sign in; the infra agent writes infra/secrets/rclone.conf with a [gdrive] remote (type = drive, scope = drive, the token from the authorize step) and copies it to the Pi. The Drive destination root folder id is 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV (OQ-4, answered); it is the value of RCLONE_FOLDER_ID in the Pi's .env.
Restore (documented step by step in infra/RESTORE.md):
docker compose stop server.- Download the chosen
kvitt-*.sqlite.gzandpictures-*.tar.gzfrom Drive (rclone or browser) to the Pi. gunzip;sqlite3 <file> "PRAGMA integrity_check"must printok.- Move the current
/data/kvitt.sqlite*aside (never delete), place the restored file as/data/kvitt.sqlite, extract pictures into/data/pictures. docker compose run --rm server rotate-epoch.docker compose start server; check/health.- Open the app on both phones; they detect the new epoch and re-push their data (§14.4.8). Check the balances match on both phones.
10.10 Operations
- Logs:
docker compose logs <service>. - Disk: SQLite is a few MB; pictures under 1 MB; APKs about 20 MB each, 5 kept.
- Upgrades: build, push,
infra/deploy.sh <tag>. Migrations run on start; a failed migration exits non-zero, the container restarts in a loop, and the previous tag is redeployed withinfra/deploy.sh <previous tag>(migrations are forward-only, so the infra plan's runbook says to restore the pre-upgrade backup if a migration had partially applied; sqlx runs each migration in a transaction, so this should not happen). - Before every deploy that includes a migration,
infra/deploy.shtriggers a backup run and waits forlast-successto update. - Memory check:
docker stats --no-streamafter the release milestone; server RSS must be under 100 MB with both phones syncing.
11. Milestones
Each milestone is one plan (PLAN-<id>.md, written by the orchestrator's plans agent from this section) run by one Opus agent in its own worktree, unless the row says otherwise. "Gate" is what must pass before landing. The Android gate is tools/gradle-serial.sh check; the server gate is cargo fmt --check && cargo clippy -- -D warnings && cargo test in server/; "both gates" means both. HUMAN steps are what only the owner can do.
11.1 Dependency graph
M0 spec ──┬─► U1 directions ─(owner picks)─► U2 design spec ─► §9.7 colours ─► M6 design ─► M7a..M7f features ──┐
│ │
├─► M1 contracts + vectors ─┬─► M2a server skeleton + domain ─┬─► M3 server core API ───────────┐ │
│ │ ├─► M4 server sync/audit/push ─┬──┤ │
│ │ │ └──┼─► M9 importer
│ └─► M2b android skeleton + domain ┴─► M5 android data layer ───────┼───────┤
│ │ ▼
└─► M2c-dev infra for development (Mac compose, Firebase, local backup) ─────────────────────┴──► M8 app integration
│
M2c-pi infra on the Pi (Pi, TLS, Gitea, router, Drive) ◄─────┤
M10 first release ◄── M2c-pi, M8 ◄──┘
M11 import real history ◄── M9, M10
After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001).
11.2 Milestone table
| Id | Name | Deliverable | Gate | HUMAN | Depends on |
|---|---|---|---|---|---|
| M0 | Spec v0.1 | SPEC.md, filled CLAUDE.md, repository bootstrap |
none (docs only) | Review the spec; answer §12 | none |
| U1 | UX directions | §13.4 phase 1 files in docs/ux/ |
Files exist and open; each direction shows the six phase-1 screens of §13.4 (S-4, S-7, S-5, S-9, S-11, S-8) in light and dark; Swedish copy, no placeholder text | Review 2-3 directions, pick one, give comments | M0 |
| U2 | UX design spec | §13.4 phase 2 files in docs/ux/ |
Every string key has en and sv; every screen has every state that design-spec.md lists for it (§13.3) |
Approve the design spec | U1 and the owner's pick |
| M1 | Contracts and vectors | contracts/openapi.yaml, contracts/schema/server-v1.sql, contracts/test-vectors/*.json, contracts/examples/**/*.json, tools/contracts-check.py |
python3 tools/contracts-check.py passes: all JSON parses, every vector file matches §14.5.5, a stdlib-only Python reference of §9.1, §9.3, §9.4 reproduces every expected value, every example validates against the shapes of §14.3 by key set |
none | M0 |
| M2a | Server skeleton and kvitt-domain |
Cargo workspace, three crates, config, DB pool, migration 0001_init.sql byte-identical to contracts/schema/server-v1.sql, error type with every §14.3.2 code, auth extractor (§8.2), all routes of §7 registered with handlers returning 501 not_implemented, utoipa document equal to contracts/openapi.yaml, GET /health and GET /setup/status implemented, kvitt-domain passing all vectors plus next_occurrence_date, server/Dockerfile; shell files in M3, M4 and M9 paths (D-004); the seams of §6.3 (D-015) |
Server gate (includes the OpenAPI match test, the migration identity test, the vector tests) | none | M1 |
| M2b | Android skeleton and :domain |
Gradle project in android/ with every module of §6.2, version catalog, architecture check, :core:model types and repository interfaces of §14.2.4, :domain passing all vectors (including analytics), :core:fakes with the empty scenario (§6.2, D-018) for each repository, the Room 3 entities of §14.2.1 and §14.2.2 and KvittDatabase version 1 in :data with the exported schema (D-012), screenshot tests with Roborazzi (D-010), module shells for every module with one empty entry composable per feature (D-004), the feature convention of §6.2 in build-logic (D-017), :app launching to an empty themed screen, :app string files with the app name only in both languages, lint MissingTranslation as an error in every module |
Android gate | Plug the phone in once to confirm installDebug works |
M1 |
| M2c-dev | Infra for development (D-001, D-022) | Landed c885aba. infra/dev/compose.yaml (server built from server/Dockerfile, no Caddy, no TLS, bind-mounted data, infra/secrets/ mounted read-only at /secrets, backup runnable on demand), infra/dev/.env.example, the dev scripts in infra/dev/ (build, up, logs, backup, copy-google-services.sh), infra/README.md (dev stack and Firebase), infra/backup/{Dockerfile, backup.sh} (Drive upload skipped when RCLONE_FOLDER_ID is empty), Firebase project and apps through the REST API (§10.7), service-account key and google-services.json in infra/secrets/ |
docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config succeeds; shellcheck $(find infra -name '*.sh') clean; on the Mac (with the M2a image): GET /health answers 200 from the Mac; one local backup run produces a snapshot that passes PRAGMA integrity_check |
gcloud auth login; on the phone on the home Wi-Fi, open the dev server's /health and answer yes/no that it shows "status":"ok" |
M0 (uses the M2a image for the health check when available) |
| M2c-pi | Infra on the Pi (D-001) | infra/ Pi compose stack, Caddyfile, cert-sync image, build, deploy and publish scripts, infra/README.md (Pi provisioning, router forwarding, trust of the gitea.lan registry), infra/RESTORE.md, Drive backups to folder id 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV, Gitea Actions image build from the owner's example workflow in .gitea/workflows/, the /opt/kvitt/import bind mount (§10.2) |
docker compose -f infra/compose.yaml config succeeds; shellcheck $(find infra -name '*.sh') clean; on the Pi: cert-sync fetched a valid certificate, https://icehack.asuscomm.com:8443/health answers from the phone on mobile data; one backup file appears in the Drive folder |
Answer OQ-3; grant at the start of the sprint the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive (D-023); set up key-based SSH from the Mac to the Kvitt Pi; provide the example Gitea Actions workflow and the Gitea credentials; install Docker on the Pi if missing; add gitea.lan to insecure-registries on the Pi; set the router forward 8443 → Kvitt Pi; add the cert-sync public key to the Home Assistant SSH add-on; rclone authorize "drive" |
M2c-dev, M8 |
| M3 | Server core API | Landed 503cee5 (D-030 to D-034). E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (src/api/rate_limit.rs), bootstrap, pair-device, the recurring generation function of §6.3 (D-015) |
Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, next_occurrence_date) |
none | M2a |
| M4 | Server sync, audit, push, scheduler | Landed bd6f10a (D-035 to D-039). E-31, E-32, the real AuditWriter (writer and diff) and PushQueue (FCM sender), src/tasks/** starting them, sync_mutations pruning daily at 04:00 (D-021), scheduler, rotate-epoch |
Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) |
| M5 | Android data layer | Landed c28c52b (D-026 to D-029, D-040 to D-043). DAOs on M2b's Room 3 entities and KvittDatabase v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater |
Android gate; DTO round-trip tests over every file in contracts/examples/; sync engine tests with Ktor MockEngine covering §14.4 including the 14-day offline case (AC-15.6) |
none | M1, M2b |
| M6 | Design system | :core:design theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts, formatters (§9.8), shared strings (including the notification copy) per docs/ux/design-spec.md and docs/ux/strings.csv, screenshot tests of every component in light and dark, Swedish and English |
Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b, the §9.7 colour change |
| M7a | Feature: onboarding | :feature:onboarding screens S-1, S-2, S-3, S-18 |
Android gate with screenshot tests of every state, sv and en, light and dark | none | M6 |
| M7b | Feature: home and account | :feature:home, :feature:account screens S-4, S-5, S-6, S-16, S-17 |
as M7a | none | M6 |
| M7c | Feature: expense | :feature:expense screens S-7, S-8 |
as M7a | none | M6 |
| M7d | Feature: settle | :feature:settle screens S-9, S-10 |
as M7a | none | M6 |
| M7e | Feature: analytics | :feature:analytics screens S-11, S-12 |
as M7a | none | M6 |
| M7f | Feature: settings | :feature:settings screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 |
as M7a | none | M6 |
| M8 | App integration | :app navigation and deep links, Hilt bindings from interfaces to :data and :domain, :adapter:fcm, notification building (AC-13.5), update banner wiring, revoke wipe, network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), google-services.json copied with infra/dev/copy-google-services.sh |
Android gate; on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced | Tap through the pairing and the offline test as relayed by the orchestrator | M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server) |
| M9 | Splitwise importer | Landed ab298ab (D-044 to D-046). kvitt-import per §10.8 with tests over synthetic CSV fixtures (crates/kvitt-import/tests/import.rs), reconciliation against the totals rows, the committed category table |
Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 |
| M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | docs/validation/first-release.md and docs/validation/restore-drill.md complete; server RSS under 100 MB |
Create the release keystore with the keytool command of §10.6, outside the repository; install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive |
M2c-pi, M3, M4, M8 |
| 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, 0 expected after it). Real import run on the Pi per §10.8 "Production run" (D-025), docs/validation/splitwise-import.md with per-account totals compared to Splitwise's balances (aggregates only) |
Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 |
11.3 Notes per milestone
- M1 writes the OpenAPI file by hand from §14.3 (no code exists yet). M2a then annotates handlers so the generated document matches it; the equality test compares parsed values, not text (D-016), after the normalisations of §14.8 (D-005). M2a may change
contracts/openapi.yamlonly representationally (key order,$refnaming, description wording), listed in the plan report, with no spec bump (§14.8). Any semantic change follows the contract-change rule (§14.8). - M2a owns the router and registers every route up front so that M3 and M4 only fill in handler bodies in their own files. The same for the utoipa
ApiDoc. M2a creates the shell files of the later server milestones (D-004, §14.7) and the seams of §6.3 (D-015).server/rust-toolchain.tomlpins the exact current stable Rust release and the Dockerfile uses the same version (§10.3). - M2b writes the
*Stateclasses and repository interfaces of §14.2.4 so that M5 and M7 work against the same interfaces in parallel. It creates a module shell for every module of §6.2, with one empty entry composable per feature module (D-004), puts the feature convention of §6.2 (D-017) into the feature convention plugin, and fixes howresolveRangesignals an invalidCUSTOMrange (§14.5.4, D-018:IncompleteCustomRangeException, D-011). Its screenshot-test choice is D-010 (Roborazzi). It also created the Room 3 entities andKvittDatabasev1 in:data(D-012), so M5 builds on them. - M5 owns
:dataand builds on the entities,KvittDatabasev1 and the exported schemaandroid/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.jsonthat M2b created (D-012); it adds DAOs, the API client, sync and the repositories. Room 3 migrations take anSQLiteConnection(§14.2.3). - M7x features use
:core:fakesonly; they never depend on:data. Each feature agent adds its fake scenarios inandroid/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/<feature>/and nowhere else in that module. Each fills in its own module shell from M2b, follows the feature convention of §6.2 and takes shared UI from:core:design(D-019). - M2c-dev / M2c-pi (D-001): M2c-dev builds only what development needs on the Mac and runs now; M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) and runs after M8, before M10.
- M4 (landed
bd6f10a) addedinto_make_service_with_connect_info::<SocketAddr>()toserveso the per-IP rate limits of §8.6 see the peer address (D-032). Its tests are three binaries,tests/sync_api/,tests/push_fcm/andtests/scheduler_daily/(§14.7); the scheduler lives insrc/tasks/scheduler.rs. - M8 is the only plan that edits
:appafter M2b. M5's contract requests (v0.5)::appprovidesAppBuildInfo(versionCode, versionName, debug)and includesDataBindingsModulein the release graph (D-041); WorkManager is configured withKvittWorkerFactoryand the default initializer disabled;:appcallsSyncTriggers.onAppStart,onForegroundandsyncForPush, andUpdateRepository.checkon cold start and every resume; theImageLoaderthat:dataprovides is set as Coil's singleton. It owns the:appitems network security config (§8.5), manifest hardening (§8.8), release signing and version properties (§10.6), and copiesgoogle-services.jsonfrominfra/secrets/withinfra/dev/copy-google-services.sh(§10.7). - M10: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 (D-024).
- M2c-pi: the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint (D-023).
- M9 (landed
ab298ab) reuses the server's own push validation (kvitt_server::api::validate,rows) and theSqlAuditWriter, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary,crates/kvitt-import/tests/import.rs, over synthetic fixtures only; one test checks the §10.8 category table againstdata/splitwise_categories.csv. - 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 and 0 are expected after it, to be confirmed by the next dry run.
12. Open questions
Only things that cannot be decided without the owner. Everything else in this spec is decided.
| Id | Question | Needed by | Default if unanswered |
|---|---|---|---|
| OQ-1 | Answered (D-001): the registry is a Gitea instance on another Pi at http://gitea.lan, plain HTTP, reachable only on the home Wi-Fi; images are built by Gitea Actions later from the owner's example workflow. |
M2c-pi | answered |
| OQ-2 | Answered (D-003): two exports, English and Swedish header variants, member columns in either order, ISO dates, point decimal, double-quoted fields, trailing empty line and Total balance / Totalsumma row, payment category Payment / Betalning, all rows SEK. Group "hemma" becomes account "Hemma", the general group becomes account "Vardag". Details §10.8 and PLAN-M9. |
M9, M11 | answered |
| OQ-3 | TODO(spec) LAN address and SSH user of the Kvitt Pi; LAN address, SSH port and user of the Home Assistant Pi; the directory holding fullchain.pem and privkey.pem there (expected /ssl on Home Assistant OS); which SSH add-on is used. |
M2c-pi | none; M2c-pi blocks on it |
| OQ-4 | Answered: the Google Drive backup folder id is 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV (§10.9). |
M2c-pi | answered |
13. UX sprint
A brief for one UX agent (Opus) who has read this spec and nothing else. It runs right after M0, in parallel with M1, and before any UI work (M6, M7). It produces proposals; the owner chooses; the UX agent then writes the binding design spec. The UX agent writes no Kotlin.
13.1 Context the UX agent must design for
- Two adults in Sweden, one household, using Samsung Galaxy S26 (about 411 × 891 dp viewport, 6.3") and S26+ (about 411 × 891 dp at a higher density, 6.7"). Portrait only. Android 16, One UI on top; the app uses Material 3 (Expressive where stable) and must not imitate One UI.
- Most sessions are 10 seconds: add an expense right after paying. The add flow is the most important flow; it must need at most: open app, tap add, type amount, type description, tap save (category and account defaulted, AC-8.6).
- The second most important view is "who owes whom" on Home.
- Swedish is the primary language (both users' phones are Swedish), English must fit too. Swedish words are long ("Utgiftskonto", "Återkommande"); designs must survive them at 200 % font scale.
- Light and dark theme follow the system. Dynamic colour (Material You wallpaper colours) is offered as an option per direction only if the direction still works with it; the default is the direction's own palette.
- Money must be legible at a glance: amounts in tabular figures, direction ("owes you", "you owe") always in words plus colour, never colour alone.
- Accessibility: touch targets ≥ 48 dp, contrast ≥ 4.5:1 for text, TalkBack labels for every icon-only control, nothing conveyed by colour only, layouts that reflow at 200 % font scale.
- Category icons are Material Symbols from the set in §9.7; category colours are the tokens of §9.7 (the UX agent may change hex values, not token names, and must give light and dark values with contrast checked against both surfaces).
- Users are shown by avatar (picture or initials, AC-4.5) and display name. The viewer is always "you" / "du".
13.2 Navigation model (binding unless a direction proposes a better one with reasons)
- Bottom navigation with three destinations: Home (Översikt), Analytics (Statistik), Settings (Inställningar).
- A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail.
- Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back.
- The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room).
13.3 Screens and what each must show
Each screen must be designed in these states where they apply: loaded, empty, offline (with pending changes), loading (first sync only; afterwards data is local), error (a failed online action), and, for lists, long content (long Swedish names, 7-digit amounts). Which states apply to which screen is listed per screen in U2's docs/ux/design-spec.md; that list is the binding one for U2's gate and for the feature milestones' screenshot tests.
| Id | Screen | Must show | Actions | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); invite code field (12 characters, shown in groups of 4); explanation of where the code comes from; progress while pairing; errors invite_invalid, network error, rate_limited |
Pair; paste code | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| S-2 | Partner profile step | Display name field (prefilled), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob") | Continue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any | Invite partner (goes to S-21); Settings | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16; update banner (S-17) when available; list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| S-5 | Account detail | Account name, default split ("50 / 50" with names), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend), each expense row per AC-8.10, each settlement row per AC-11.7; archived badge if archived; pending-sync marker per row | Add expense; Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state | Save; Cancel | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| S-7 | Expense editor | Amount entry (large, numeric keyboard, kr); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories); date (today default, date picker); payer toggle (two avatars: "Paid by"); split section: mode chooser (default "Account default 60/40", ratio "Custom ratio", exact "Exact amounts", full "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode |
Save; Cancel; Delete (edit mode) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: " when generated; timeline (AC-12.4, AC-12.5); "deleted" state when opened from a notification of a deletion | Edit; Delete | S-9 | Settle up | Direction ("Anna pays Jacob" with a swap control); amount; allocation list: account name, account debt, editable allocated amount, over-settle warning per row (AC-11.5); remaining-to-allocate indicator; "Add account"; date; note; per-account variant with a single fixed row | Confirm; Cancel | S-10 | Settlement detail | Payer, recipient, amount, date, note, allocations per account; timeline | Edit; Delete | S-11 | Analytics | Range selector (presets of AC-14.2 plus custom); granularity toggle month/week; measure toggle household / my share; group-by toggle category/account; total for range; chart 1 stacked bars over time with legend; chart 2 category breakdown; chart 3 account breakdown; empty state ("No expenses in this period") | Change filters; tap a bar or a row to open S-12 | S-12 | Analytics drill-down | Title naming the filter ("Mat & hushåll, oktober 2026"); total; expense rows as in S-5 | Open expense | S-13 | Settings hub | Own avatar and name; partner's avatar and name; rows: Profile, Categories, Recurring expenses, Devices, Invite partner or Pair a device (admin), Sync status, About | Navigate | S-14 | Profile | Avatar with change/remove; display name; "Needs connection" state | Save | S-15 | Categories | List: icon, colour, name, archived section; editor: name (with "Reset name" for defaults), icon grid (30 icons of §9.7), colour swatches (12 tokens); delete or archive with the reason when delete is not allowed | Add; Edit; Reorder (drag); Archive; Delete | S-16 | Sync status | Last successful sync time; pending changes count with a list; "Changes not synced" list from sync_rejections with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync now" |
Sync now; open a rejected change (read-only); dismiss it | S-17 | Update available | Banner on Home with version name; dialog explaining "Install unknown apps" the first time; download progress; failure | Update; Later | S-18 | Device removed | Explanation that this phone's access was removed and local data deleted | Pair again (goes to S-1) | S-19 | Recurring expenses | List: description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 (no date) plus day of month, first month, optional last month, pause switch | Add; Edit; Pause/Resume; Delete | S-20 | Devices | Rows per AC-3.1 | Revoke (with confirmation) | S-21 | Invites | For the admin: "Invite partner" (only before the partner joins) and "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry, and a share action (Android share sheet); list of pending invites with cancel | Create; Share; Cancel | S-22 | About | App version name and code, server URL, server version (from /health), open-source licences |
Check for update | Type | Title | Body | expense_created |
" added an expense" | " · · " | expense_updated |
" changed an expense" | " · " | expense_deleted |
" deleted an expense" | " · " | settlement_created |
" recorded a payment" | " paid " | settlement_updated |
" changed a payment" | " paid " | settlement_deleted |
" deleted a payment" | "" | recurring_expense_created |
"Recurring expense added" | " · · " | sync_batch |
" made changes" | "Open Kvitt to see them" | any, when the sync failed | " changed something in Kvitt" (or "Kvitt" without actor name) | "Open Kvitt to see it" | Element | English | Swedish | App name | Kvitt | Kvitt | Download button | Download Kvitt | Ladda ner Kvitt | Open button | Open in Kvitt | Öppna i Kvitt | Instruction sentence | Install Kvitt, then open this link again on your phone. | Installera Kvitt och öppna sedan den här länken igen på din telefon. | Entity class | Table | Columns (server columns unless marked local) | Primary key | Indices | UserEntity |
users |
id, role, display_name, picture_version?, created_at, updated_at, server_version, local local_updated_at |
id |
none | AccountEntity |
accounts |
id, name, created_by_user_id, created_at, updated_at, archived_at?, deleted_at?, server_version, local local_updated_at |
id |
created_at |
AccountDefaultShareEntity |
account_default_shares |
account_id, user_id, share_bp |
(account_id, user_id) |
none | CategoryEntity |
categories |
id, default_key?, name?, icon, color, sort_order, created_at, updated_at, archived_at?, deleted_at?, server_version, local local_updated_at |
id |
none | RecurringTemplateEntity |
recurring_templates |
every server column, plus next_occurrence_date? (from the wire), local local_updated_at |
id |
account_id |
RecurringTemplateSplitEntity |
recurring_template_splits |
template_id, user_id, share_bp?, share_ore? |
(template_id, user_id) |
none | ExpenseEntity |
expenses |
id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, split_mode, recurring_template_id?, recurring_period?, created_by_user_id? (null until the server accepted the create), updated_by_user_id?, created_at, updated_at, deleted_at?, server_version? (null until accepted); local pending, pending_mutation_id?, local_updated_at |
id |
account_id, expense_date, pending |
ExpenseSplitEntity |
expense_splits |
expense_id, user_id, share_ore, share_bp? |
(expense_id, user_id) |
none | SettlementEntity |
settlements |
id, from_user_id, to_user_id, amount_ore, settlement_date, note, created_by_user_id?, updated_by_user_id?, created_at, updated_at, deleted_at?, server_version?; local pending, pending_mutation_id?, local_updated_at |
id |
settlement_date, pending |
SettlementAllocationEntity |
settlement_allocations |
settlement_id, account_id, amount_ore |
(settlement_id, account_id) |
account_id |
AuditEntryEntity |
audit_log |
id, entity_type, entity_id, action, source, actor_user_id, client_at, server_at, changes (JSON text as received), server_version |
id |
(entity_type, entity_id) |
Entity class | Table | Columns | Primary key | SyncStateEntity |
sync_state |
id (always 1), cursor Long (last applied next_since, 0 initially), epoch String?, last_success_at String?, last_attempt_at String?, last_error_code String? (an error code of §14.3.2 or network), clock_skew Boolean |
id |
SyncRejectionEntity |
sync_rejections |
mutation_id, entity_type, entity_id, reason (§14.4.6), message, rejected_payload (JSON of the entity as pushed), server_current (JSON or null), rejected_at String, dismissed Boolean |
mutation_id |
code |
HTTP | Meaning | invalid_request |
400 | Malformed JSON, missing required field, wrong type, bad query parameter, empty PATCH | invite_invalid |
400 | Invite code unknown, expired, used, cancelled or malformed | unauthorized |
401 | Missing, malformed or unknown bearer token | device_revoked |
401 | The token's device is revoked | forbidden |
403 | Role does not allow the action (§8.4), or editing another user's profile | not_found |
404 | Unknown or deleted id in the path; unknown APK file; no APK published; no picture | household_full |
409 | Partner invite or accept when two users exist | household_incomplete |
409 | Account, template, expense or settlement write before the partner joined | name_taken |
409 | Account name already used by a non-deleted account | account_not_empty |
409 | Account delete while referenced (AC-6.5) | category_in_use |
409 | Category delete while referenced (AC-7.6) | category_is_default |
409 | Delete of a default category, or name: null on a non-default category |
cannot_revoke_current_device |
409 | Revoke of the calling device | no_accounts |
409 | Settlement proposal with no non-deleted accounts | cursor_ahead |
409 | GET /sync with since greater than the server's current sequence value |
payload_too_large |
413 | Body over the limit of §8.6 | unsupported_media_type |
415 | Picture with another content type | validation_failed |
422 | A value breaks §9.6 or a split/settlement rule | image_invalid |
422 | Picture cannot be decoded or exceeds 8000 × 8000 | batch_too_large |
422 | Number of mutations in POST /sync outside 1 to 100 (D-036) |
protocol_unsupported |
426 | Missing or unsupported X-Kvitt-Protocol |
rate_limited |
429 | §8.6, with Retry-After |
internal |
500 | Unexpected server error (logged with a request id, returned in message) |
not_implemented |
501 | Route registered but not yet implemented (only during milestones M2a to M4); attached to no operation in contracts/openapi.yaml |
unavailable |
503 | Database busy beyond busy_timeout, or server shutting down |
reason (exhaustive) |
When | Phone's user-facing explanation (S-16) | stale |
Someone saved a newer version first | "Your change was replaced by a newer change from " | deleted |
The row was deleted | "This was deleted before your change arrived" | validation_failed |
The row breaks a rule (should not happen with a correct phone) | "This change was not valid and was not saved" | reference_not_found |
Its account or category was deleted | "Its account or category no longer exists" | clock_skew |
Phone clock more than 10 minutes ahead | "Your phone's clock is wrong. Fix it and sync again" | Entity | Field | Value | expense | account_id, category_id, payer_user_id |
string (uuid) | expense | amount_ore |
int | expense | description, notes |
string | expense | expense_date |
string (date) | expense | split_mode |
string (enum) | expense | splits |
array of Split sorted by user_id |
settlement | from_user_id, to_user_id |
string (uuid) | settlement | amount_ore |
int | settlement | settlement_date |
string (date) | settlement | note |
string | settlement | allocations |
array of Allocation sorted by account_id |
both (delete only) | deleted_at |
string (ts) | Item | Value | Mutations per push | 100 | Rows per pull page | default 500, max 1000 | Debounce after a local write | 2 seconds | Periodic background sync | 15 minutes (WorkManager minimum), requires a connected network | FCM-triggered sync | expedited one-off work | Retry backoff | exponential from 30 seconds (WorkManager) | sync_mutations retention |
90 days, pruned daily at 04:00 Europe/Stockholm; rows with received_at strictly older than 90 days are deleted (D-021, D-039) |
Tombstone retention | forever | Token lifetime | until revoked | Notification sync wait (AC-13.5) | 20 seconds | Function | Kotlin | Rust | Split | fun computeSplit(amountOre: Long, payerUserId: String, userIds: List<String>, spec: SplitSpec): SplitResult |
pub fn compute_split(amount_ore: i64, payer_user_id: &str, user_ids: &[String], spec: &SplitSpec) -> Result<BTreeMap<String, i64>, SplitError> |
Balances | fun computeBalances(userIds: List<String>, accountIds: List<String>, expenses: List<BalanceExpense>, settlements: List<BalanceSettlement>): Balances |
pub fn compute_balances(user_ids: &[String], account_ids: &[String], expenses: &[BalanceExpense], settlements: &[BalanceSettlement]) -> Balances |
Proposal | fun proposeAllocation(amountOre: Long, accounts: List<AccountPosition>): AllocationResult |
pub fn propose_allocation(amount_ore: i64, accounts: &[AccountPosition]) -> Result<AllocationProposal, AllocationError> |
Settlement check | fun validateSettlement(amountOre: Long, fromUserId: String, toUserId: String, userIds: List<String>, allocations: List<Allocation>): SettlementError? |
pub fn validate_settlement(amount_ore: i64, from_user_id: &str, to_user_id: &str, user_ids: &[String], allocations: &[Allocation]) -> Result<(), SettlementError> |
Over-settle warnings | fun overSettledAccounts(allocations: List<Allocation>, accounts: List<AccountPosition>): List<String> |
pub fn over_settled_accounts(allocations: &[Allocation], accounts: &[AccountPosition]) -> Vec<String> |
Recurring dates (D-015) | none | next_occurrence_date and the clamped occurrence date of a period (module recurring.rs); M2a fixes the exact signatures and lists them in its report |
type |
entity_type |
entity_id |
actor_user_id |
count |
Sent to | expense_created |
expense |
expense id | creator | 1 |
other user's devices | expense_updated |
expense |
expense id | editor | 1 |
other user's devices | expense_deleted |
expense |
expense id | deleter | 1 |
other user's devices | settlement_created |
settlement |
settlement id | creator | 1 |
other user's devices | settlement_updated |
settlement |
settlement id | editor | 1 |
other user's devices | settlement_deleted |
settlement |
settlement id | deleter | 1 |
other user's devices | recurring_expense_created |
expense |
expense id | template creator | 1 |
both users' devices | sync_batch |
"" |
"" |
pusher | number of notifiable changes, as a string | other user's devices | Paths | Owner | After the owner lands | SPEC.md, CLAUDE.md, README-agents.md, docs/HANDOFF.md, docs/decisions.md, PLAN-*.md, prompts/, tools/ (except tools/contracts-check.py) |
docs agent briefed by the orchestrator | same | contracts/**, tools/contracts-check.py |
M1 | contract-change plans only (§14.8) | server/Cargo.toml, server/Cargo.lock, every crate's Cargo.toml, server/rust-toolchain.toml, server/Dockerfile, server/crates/kvitt-domain/** |
M2a | contract-change plans only for kvitt-domain; M3, M4 and M9 may add dependency lines to a crate's Cargo.toml (second to land resolves Cargo.lock on rebase) |
kvitt-server: migrations/, src/{main,lib,config,error,router,openapi,auth,time}.rs (including the serve and migrate code), src/db/**, src/wire/**, src/api/{mod,health,setup}.rs, src/cli/{mod,healthcheck}.rs, M2a's own tests (any tests/*.rs without the prefixes core_, sync_, scheduler_, push_) |
M2a | contract-change plans; M3/M4 may add one line per new module to mod.rs files they need; M3 edits the one marked rate-limit line in router.rs (D-015) |
kvitt-server: src/api/{invites,landing,me,pictures,devices,accounts,categories,recurring,proposal,apk,rate_limit}.rs, src/cli/{bootstrap,pair_device}.rs, tests/core_*.rs (M3's tests are one binary, tests/core_api/) |
M3 (M2a creates the shells, D-004) | later server plans | kvitt-server: src/api/sync.rs, src/tasks/**, src/sync/**, src/audit/**, src/push/**, src/scheduler/**, src/cli/rotate_epoch.rs, tests/sync_*.rs, tests/scheduler_*.rs, tests/push_*.rs (M4's tests are the binaries tests/sync_api/, tests/push_fcm/, tests/scheduler_daily/; its scheduler is src/tasks/scheduler.rs; there is no src/scheduler/) |
M4 (M2a creates the shells, the tasks::start stub and the AuditWriter/PushQueue traits with no-op implementations as described under "Server seams" in §6.3, D-004, D-015) |
later server plans; the trait signatures change only with M3, M4 and M9 agreeing through the orchestrator | server/crates/kvitt-import/** |
M9 (M2a creates a crate that builds with a usage-only main.rs, D-004) |
same | android/settings.gradle.kts, android/build.gradle.kts, android/gradle.properties, android/gradle/**, android/build-logic/**, the module shells (build file, manifest, one empty entry composable per feature) of :core:design, :data, :adapter:fcm and every :feature:* (D-004) |
M2b | later plans may add catalog entries and module includes (rebase resolves) | android/core/model/**, android/domain/** |
M2b | contract-change plans only | android/core/fakes/** |
M2b | M7x add files only under fakes/<feature>/ |
android/core/design/** |
M6 | M7x may not edit; requests go through the orchestrator | android/data/** |
M5 (M2b created the Room 3 entities, KvittDatabase v1 and the exported schema, D-012) |
later data plans; the exported schema is a contract file (§14.8) | android/adapter/fcm/**, android/app/** (after the M2b shell) |
M8 | later integration plans | android/feature/onboarding/** |
M7a | android/feature/home/**, android/feature/account/** |
M7b | android/feature/expense/** |
M7c | android/feature/settle/** |
M7d | android/feature/analytics/** |
M7e | android/feature/settings/** |
M7f | infra/** |
M2c-dev, then M2c-pi (D-001) | M10 updates runbooks | .gitea/workflows/** |
M2c-pi (D-023) | later infra plans | git-ignored infra/secrets/** in the main checkout |
M2c-dev (D-022), then M2c-pi | M8 copies google-services.json from it (§10.7) |
docs/ux/** |
U1, U2 | docs/validation/<file> |
the milestone that names the file | Version | Date | Change | 0.1 | 2026-10-17 | First version, written from the owner interview: scope, requirements, data model, stack, API outline, security, business rules, deployment, milestones, UX sprint brief, contracts (schema v1, Room v1, OpenAPI outline, sync protocol v1, domain functions and vectors, push payload v1, ownership map). | 0.2 | 2026-10-17 | Owner answers. D-001: development-first deployment on the Mac; M2c split into M2c-dev (now) and M2c-pi (after M8, before M10); registry is Gitea at http://gitea.lan (OQ-1 answered). D-002: local backup files deleted as soon as confirmed on Drive. D-003: Splitwise CSV format confirmed for English and Swedish exports, Swedish category names mapped, accounts "Hemma" and "Vardag" (OQ-2 answered). OQ-4 answered (Drive folder id). §10, §11, §12, §14.7 updated. Clarifications from M1 (no new decisions): non-deleted rows only into domain functions, analytics vector keys, sync push result key sets, E-1 503 body, optional/nullable request fields of E-3, E-12, E-17, E-33, since required on E-31, proposal amount range, bp above 10000, resolveRange edge cases, §9.6 limits not in JSON Schema, example directory names, not_implemented on no operation (§14.3, §14.4.3, §14.5). |
0.3 | 2026-10-17 | Orchestrator resolutions of the plans' open points. D-004: M2a and M2b create shell files and modules in later milestones' paths, ownership of Cargo.lock and every Cargo.toml (§11.2, §11.3, §14.7). D-015: server seams: middleware in router.rs, rate limit in src/api/rate_limit.rs on one marked line, src/tasks/ for M4, AuditWriter/PushQueue traits with no-op implementations, next_occurrence_date and day clamping in kvitt-domain (Rust only), M3 writes the recurring generation function, M4 lands after M3 (§6.3, §9.5, §11, §14.5.2, §14.7). D-016: the OpenAPI equality test compares parsed values; representational edits need no spec bump (§11.3, §14.8). D-017: feature convention, analytics types in se.hagstedt.kvitt.model.analytics, string placement and key prefixes, MissingTranslation in every module (§6.2, §13.4, §14.5.4, §14.7). D-018: empty fake scenario; resolveRange throws a typed IllegalArgumentException subclass for an incomplete CUSTOM range (§6.2, §14.5.4). D-019: ledger rows and expense editor fields in :core:design (§6.2, §11.2). D-020: no bundled font, Material 3 default typeface (§6.2, §13.4). D-021: sync_mutations pruning daily at 04:00 (§14.1, §14.4.9). D-022: development infrastructure as M2c-dev built it (c885aba): Firebase through the REST API with a gcloud token, no Firebase CLI, secrets and google-services.json in infra/secrets/ copied by infra/dev/copy-google-services.sh, Firebase resources and the kvitt-fcm service account (roles/firebasecloudmessaging.admin) exist; dev stack without Caddy or TLS, bind-mounted data, infra/secrets/ mounted at /secrets, env file infra/dev/.env.example, healthcheck used by compose; the server starts when KVITT_TRUSTED_PROXY does not resolve; new M2c-dev gate (§10 intro, §10.4, §10.7, §11.2, §14.7). D-023: M2c-pi allowances, HUMAN steps, .gitea/workflows/, secret modes for uid 1000 (§10.2, §10.3, §11.2, §14.7). D-024: release keystore command, kept outside the repository (§10.6). D-025: production import procedure and the /opt/kvitt/import bind mount (§10.2, §10.8). Without a decision id: exact Rust version pin shared by rust-toolchain.toml and the Dockerfile (§10.3); the §9.7 colour change lands before M6 (§11.1); per-screen states are U2's list (§13.3); landing page copy (§13.3, AC-2.5); M8 owns network security config, hardening, signing and version properties (§11.2); U1's gate names the six phase-1 screens (§11.2); category table path (§10.8); implementation agents never edit the spec or the decisions log (§14.8). |
0.4 | 2026-10-17 | Decisions from the M2a (fdf2093) and M2b (fad6fc9) implementation reports. D-005: the eight normalisations of the OpenAPI equality test and the CommonRules modifier in openapi.rs (§11.3, §14.8). D-006: the wrong-bp-sum token is bp_sum_not_10000 with an explicit serde rename in Rust; Kotlin emits the same token (§14.5.1, §14.5.3). D-007: the no-unwrap rule enforced per crate with #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] (CLAUDE.md). D-010: screenshot tests with Roborazzi 1.76.0 on Robolectric 4.17, @Config(sdk = [36]), roborazzi.test.verify=true, re-record with recordRoborazziDebug (§6.2, §11.2). D-011: IncompleteCustomRangeException for an incomplete CUSTOM range; ALL_TIME with the earliest expense after today gives today..today (§14.5.4). D-012: Room 3 (androidx.room3 3.0.3, KSP only, SQLiteDriver with sqlite-bundled, migrations on an SQLiteConnection); M2b created the entities and KvittDatabase v1, whose exported schema 1.json is a contract file; M5 builds on them (§6.2, §11.2, §11.3, §14.2, §14.2.3, §14.7, §14.8). D-013: JDK 25 --add-exports/--add-opens for Robolectric in build-logic (§6.2). D-014: failOnNoDiscoveredTests off only for modules without src/test (§6.2). D-008 and D-009 unused. Clarifications without a decision id: exact Rust pin (1.99.1) shared by rust-toolchain.toml and the Dockerfile, TARGETARCH arm64 (shipped) and amd64, distroless pinned by digest (§6.3, §10.3); chrono stands (§6.3); kvitt-domain depends only on serde (§6.3, CLAUDE.md); AuditWriter and PushQueue as built (§6.3, §14.7); protocol check before token check (§6.3, §14.3.1); 405 with an empty body and the version header (§14.3.1); body limits as router layers (§8.6); app_version_code updated only from a present, parseable X-Kvitt-App-Version (§8.2); E-1 schema_version from a code constant; E-2 initialized means an admin user exists (§14.3.4); healthcheck uses the port of KVITT_BIND; cargo run -p kvitt-server -- <command> from the workspace root (§10.4); minSdk 36, compileSdk and targetSdk 37, Material 3 1.4.0 pinned beside the Compose BOM, AGP 9 check runs only the debug unit-test variant, version code and name from kvitt.versionCode/kvitt.versionName defaulting to 1/dev, feature convention documented in android/README.md (§6.2); FieldError.error may be validation_failed for §9.6 rules without a domain token (§9.6, §14.2.4); AnalyticsState has no error field and a missing custom date resolves as today (§14.2.4, §14.5.4); DrillDownState.title stays a String of unlocalised parts and S-12 formats its own title (§14.2.4); a settlement row in an account's list shows that account's allocation (AC-11.7, §14.2.4); the AnalyticsMeasure.Share / model.Share name collision is accepted (§14.2.4, §14.5.4). |
0.5 | 2026-10-17 | Decisions from the M3 (503cee5) and M5 (c28c52b) implementation reports. M3: D-030 picture version after a removal, -removed marker, next version = highest on disk + 1, first upload 1 (AC-4.4, §10.4, E-10); D-031 failed accept = any 4xx but 429, refused until 60 minutes after the first failure, fixed windows (AC-2.12); D-032 rate-limit keying by token hash, 401 counts against the IP, never-accepted tokens refused when the IP is over, KVITT_TRUSTED_PROXY re-resolved every 60 s, last X-Forwarded-For entry, into_make_service_with_connect_info added by M4 (§8.6, §10.4, §11.3); D-033 resume moves last_generated_period only to a period at or after start_period (§9.5); D-034 strings stored trimmed, transparency flattened onto white, fcm_token non-empty or null, revoke clears fcm_token, idempotent archive keeps server_version, new category sort_order over non-deleted (§8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26). M5 (renumbered: its D-030 to D-033 are D-040 to D-043): D-026 cursor_ahead is epoch recovery (§14.4.2); D-027 clock_skew gives a new pending_mutation_id, and the phone re-stamps updated_at on the next attempt (orchestrator decision, follow-up open) (§14.4.5); D-028 periodic work kvitt-sync-periodic (§14.4.1); D-029 OnlineResult.Failed("network", null) (§14.2.4); D-040 whole-request push refusals count as no progress, 429 retries, 426 stops (§14.4.5); D-041 DataBindingsModule (§6.2); D-042 Room tests on Robolectric, migrations from the exported JSON (§6.2); D-043 pairing status and 426 version code in DataStore (§8.2, §14.2.2). D-035 to D-039 reserved for M4. Clarifications without a decision id: control characters restricted only in notes, newlines accepted in names and descriptions (§8.6); landing page without download button while no APK is published, 404 page, Referrer-Policy and Cache-Control headers (AC-2.5, §13.3); generation sets the template's updated_at (§9.5); E-28 accepts archived accounts and categories (AC-6.4); E-14 on an accepted invite is 204 with no change; /health from the healthcheck counts against loopback (§8.6); M3's tests in one binary tests/core_api/ (§14.7); sync triggers, batch and page sizes (AC-15.3, §14.4.1); 426 stops syncing until the installed version changes and HomeState has no update-required field (§14.2.4, §14.3.1); pictures always fetched at 512; NeedsInstallPermission once (AC-16.3); Rejection.summary, FieldChange display and RecurringTemplate.draft.date as documented in :data (§14.2.4); M8 contract requests (§11.3). |
0.6 | 2026-10-18 | Decisions from the M4 (bd6f10a) implementation report. D-035: a mutation whose mutation_id, entity_type or entity.id is unusable fails the whole request with 400 invalid_request; every other shape error is a per-mutation validation_failed (§14.4.4 step 3). D-036: POST /sync with 0 mutations is 422 batch_too_large, the code now reads "outside 1 to 100" (§9.6, §14.3.2, §14.4.4). D-037: a delete of a never-synced row runs the full step-8 validation; every rejection carries the stored row or null as current, also at steps 3 and 4; step 11 keeps the stored created_at; pushed ids must be canonical lowercase hyphenated UUIDs (§14.4.3, §14.4.4). D-038: FCM retries are one send plus up to three retries at 1 s, 5 s, 25 s; 401 drops the cached access token and is not retried, other 4xx are not retried; retries block the single sender task up to 31 s per device (§14.6). D-039: pruning deletes received_at strictly older than 90 days; seam change to D-015: tasks::start returns the AppState with the real push queue and serve builds the router from it, AppState::new defaults to SqlAuditWriter, NoopAuditWriter removed, NoopPushQueue kept for push disabled (§6.3, §14.1, §14.4.9; PLAN-M4). Clarifications without a decision id: §14.4.4 step 8 order is field rules, domain rules, references; a negative since is invalid_request and a pull reads one page in a single read transaction (§9.6, §14.3.4, §14.4.2); push runs in one BEGIN IMMEDIATE with a savepoint per mutation; the push queue is bounded at 100 and drops the oldest with a warning; the FCM JWT is RS256 with kid = private_key_id, the access token cached until 5 minutes before expiry, messages data-only with string values; UNREGISTERED or 404 clears the device token only if unchanged; an unusable credentials file disables push like a missing one (§6.3, §14.6); the scheduler reads the injected clock at least every 30 s, runs a missed job once after a forward jump and re-plans after a backward jump of more than a day (§9.5); rotate-epoch prints the new epoch, exit 0 or 1 on a database error (§10.4); serve uses into_make_service_with_connect_info (D-032 fulfilled, §8.6, §11.3); GET /i/ with an empty code is the HTML 404 (E-4); reqwest (rustls, aws-lc-rs, platform verifier) and jsonwebtoken added, so the arm64 image needs a C toolchain stage and the distroless image CA roots, to be confirmed by M2c-pi or M10; the dev image was rebuilt with them on the Mac on 2026-10-18 (§6.3, §10.3); M4's test binaries and scheduler path (§11.3, §14.7). |
0.7 | 2026-10-18 | Decisions from the M9 (ab298ab) implementation report and the importer rehearsal on the real exports. D-044: nine Swedish Splitwise category names added to the §10.8 table and to splitwise_categories.csv with the key of their English equivalent (Städning and Verktyg - Övrigt utilities, Tjänster home, Cykel transport, Flyg travel, Spel entertainment, Kläder shopping, Livet - Övrigt and Utbildning other). D-045: reconciliation: the run commits only if each user's balance of the whole account equals the sum of the file's totals rows (one per currency, converted with --fx); a mismatch or a missing totals row rolls back and exits 4, a dry run reports the same, an account already holding other rows rolls back, a mismatch after any --fx conversion is reported but accepted (§10.8, AC-17.5). D-046: CLI semantics: --user takes a user id or an exact unique display name; no database is created or migrated, meta.schema_version must be 1; a dry run runs the full transaction and rolls back; exit codes 0 ok, 1 database or file error, 2 usage, 3 cannot import, 4 reconciliation mismatch; the import key uses an existing account's stored name; an empty description or one over 1000 code points makes the row invalid; descriptions over 100 code points are cut with the full text in notes; settlement notes cut to 200; both nets zero is mode full on the owner; idempotency through the existing import_key columns (§10.8, AC-17.5). M9 marked landed, M11 rehearsal recorded (§11.2, §11.3). |