Files
kvitt/SPEC.md
T

371 KiB
Raw Blame History

Kvitt: specification

Version 0.19 (2026-10-18). Schema version 3. Sync protocol version 1. Push payload version 1.

This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or CLAUDE.md disagrees with this file, this file is right. Words in code are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as TODO(spec); do not invent a value for it.

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, literal Z). This form sorts correctly as a string.
  • Dates without time are ISO 8601 YYYY-MM-DD. Months ("periods") are YYYY-MM. ISO weeks are YYYY-Www with 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 lowercase snake_case strings.
  • "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:

  1. G-1 Correct money. Balances are exact to the öre and computed identically on phone and server, guaranteed by shared test vectors.
  2. 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.
  3. 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.
  4. 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.
  5. G-5 Insight. Monthly and weekly spending by category and by account, computed on the phone.
  6. G-6 Trust. Every change to an expense or settlement is visible as a timeline of who did what.
  7. 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.
  8. 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.
  9. 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 bootstrap on a database with no users creates an invite of kind admin_setup, prints its code (format §8.3), its landing link https://icehack.asuscomm.com:8443/i/<CODE> (the base URL from KVITT_PUBLIC_URL), its expiry, and exits 0.
  • AC-1.2 kvitt-server bootstrap on a database that has users prints already initialized to stderr and exits 2. It creates nothing.
  • AC-1.3 Running kvitt-server bootstrap again before the first code is used cancels the previous admin_setup invite and prints a new one.
  • AC-1.4 Accepting the admin_setup invite (FR-2) creates the admin user with the given display name and the first device, and returns a device token.
  • AC-1.5 GET /setup/status returns {"initialized": false} before AC-1.4 and {"initialized": true} after.
  • AC-1.6 kvitt-server pair-device --user <user_id> creates a device invite 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 partner invite from Settings. It is refused with household_full when two users exist. Creating a new partner invite cancels any pending one.
  • AC-2.2 The admin creates a device invite 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_setup and partner) a display name. It returns a bearer token, the kind of the accepted invite (invite_kind, D-086), the user, the device and the household's users. For a partner invite it creates the member user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050).
  • AC-2.5 GET /i/<CODE> serves an HTML landing page in Swedish if the request's Accept-Language prefers sv, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to kvitt://invite?server=<urlencoded KVITT_PUBLIC_URL>&code=<CODE> (followed by &from=<urlencoded name> when the request carries a valid from query parameter, D-087), 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 of its own: the from name is only passed on into the kvitt://invite link and never shown on the page; an invalid from (not UTF-8 after percent-decoding, a control character, or more than 40 code points after trimming) is dropped and the page still opens (D-087). While no APK is published (E-5 would answer not_found) the page omits the download button. The page is sent with Referrer-Policy: no-referrer and Cache-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=…[&from=…] and from https://icehack.asuscomm.com:8443/i/<CODE>[?from=…] (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. When the link names the inviting admin (from, E-12, D-087), S-1's help line names them (onboarding_code_help); links without a name (older links) still open, with the generic help line. The https link is declared with android:autoVerify="false". An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). A link may name any server: as built, S-1 opens its server row expanded when the link's server is not the release default (RELEASE_SERVER_URL, https://icehack.asuscomm.com:8443), so the address is visible and editable before pairing; v1 adds no confirmation step for an unusual server (security review L-6, D-116; a confirm step would need a design state, OQ-6).
  • AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with https://icehack.asuscomm.com:8443 in 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 accept http:// for localhost, 10.0.2.2 and private LAN addresses (10.*, 172.16.*-172.31.*, 192.168.*). The limit is enforced in code by SessionRepositoryImpl.normaliseServerUrl, not by the network security config, which cannot express IP ranges (§8.5, D-082). A release build therefore pairs with any https:// server a link or the user names; what S-1 shows then is in AC-2.6 (D-116).
  • AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android POST_NOTIFICATIONS). As built (M8), the request is made after the first successful sync after pairing.
  • AC-2.10 A partner pairing (E-3 invite_kind = partner, D-086) continues to a profile step (display name prefilled from the accept call, optional picture) before the Home screen. Every other pairing goes to Home, including a member pairing a second phone with a device invite.
  • 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_limited for 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 with rate_limited until 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 (the version name, "Kvitt 1.1", once the device has reported one, else the version code; D-090), and "This device" for the current one.
  • AC-3.2 The admin can revoke any device except the one in use (cannot_revoke_current_device). A member can revoke their own devices except the one in use.
  • AC-3.3 A revoked device's next request gets HTTP 401 device_revoked. The app then deletes its token, its local database and its cached pictures, and shows the "Device removed" screen with a "Pair again" button.
  • AC-3.4 A revoked device never receives pushes again.
  • AC-3.5 Devices are never deleted from the database; revocation sets revoked_at and clears the device's fcm_token (D-034).

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, and also at most 16 000 000 pixels (width × height, for example 4000 × 4000) decoding to at most 64 MB (16 MP × 4 bytes, so a 16-bit image is refused above 8 MP), checked from the image header before decoding with the decoder's limits set as well, else image_invalid (D-114); it 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 with image_invalid, unsupported_media_type or payload_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 one of two colour tokens, avatar-1 and avatar-2 (values in U2's docs/ux/design-spec.md). A user's slot is a stable function of the user id, so the same user has the same colour on both phones (D-052).
  • 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. In that state SettlementRepository.start and recompute return null and save returns SaveResult.Invalid with a payer validation_failed error; nothing throws (§14.2.4, D-111).
  • AC-5.2 The server refuses account creation, recurring template creation and any expense or settlement mutation with household_incomplete while 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_at ascending, 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 fourteen default categories (fixed ids, §14.1; the four with sort_order 25, 45, 85 and 95 were added by migration 0002, §14.1.1, D-054), 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 Dining (D-067) Restaurang & café restaurant orange 20
housing Housing Boende key indigo 25
home Home Hem home brown 30
utilities Utilities Räkningar bolt yellow 40
insurance Insurance Försäkringar shield slate 45
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
children Children Barn child_care lime 85
shopping Shopping Shopping shopping_bag pink 90
fees Fees & taxes Avgifter & skatter account_balance amber 95
other Other Övrigt category grey 100

home keeps furniture, supplies, maintenance, electronics and services; rent and mortgage belong to housing (D-054).

  • AC-7.3 Either user creates a category (online) with a name, icon and colour. New categories get sort_order = the maximum sort_order over 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_use otherwise). Default categories (with a default_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 suggestion of §9.11 for a new expense, whose last step is the category last used in that account on this phone if still active, else other (D-049); when other is archived too and no active last-used category exists, the editor shows no category until the user picks one (v0.9); editing an existing expense keeps its category; split mode default.
  • 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 default re-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 full on the payer, matching §13 copy; Swedish "Du lånade ut 205,00 kr", "Du lånade 205,00 kr", "Inte inblandad", D-052). Rows are ordered by date descending, then created_at descending.
  • 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_period and an optional end_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 = recurring and sends recurring_expense_created to 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_period is the current month if day_of_month is today or later in the phone's time zone, else the next month. A new template's day_of_month defaults to today's day of the month, so the default start_period is the current month until the user picks an earlier day (D-072). The user may choose a start_period up 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. An edited settlement opens with its saved allocation rows counted as edited by the user, so changing the amount keeps them; "Suggest again" (settle_propose_again, "Föreslå igen") re-proposes (D-074).
  • 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/proposal returns exactly what the phone's propose_allocation returns 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 /sync that produces more than 3 notifiable changes sends one sync_batch push 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 (S-8 or S-10). It opens Home for deletions, for sync_batch and for the generic notification (shown when the sync failed or the entity or its actor is missing locally) (D-085).
  • AC-13.7 The app registers for push by Firebase Installation ID (D-083): it calls register() whenever it is paired and at each app start while paired, and every id Firebase hands to onRegistered(fid) is sent as fcm_token with PUT /devices/current/push-token (E-17). The server sends pushes to that id in the FCM HTTP v1 message.fid field (§14.6, D-096). When the device is revoked (AC-3.3) the app calls unregister(). A failed E-17 send (network error, 429, 5xx) is kept and sent again after each successful sync until the server accepts it; a newer id from onRegistered replaces the kept one (PushTokenRetry in :app, D-112).
  • AC-13.8 No personal content (names, amounts, descriptions, notes) is placed in the FCM payload.
  • AC-13.9 Notifications are posted with VISIBILITY_PRIVATE and a public version carrying the generic text "Kvitt" / "Open Kvitt to see it" (notif_generic_title, notif_generic_body), so a secure lock screen shows no description, amount, account or name (security review L-7, D-116).

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. Default last_6_months.
  • AC-14.3 Granularity: month or week (ISO, Monday start). Default month.
  • AC-14.4 Measure: household (sum of full expense amounts, whoever paid) or my_share (sum of the viewer's shares). Default household.
  • AC-14.5 Group by: category or account. Default category.
  • 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/latest on every cold start and at most once per 6 hours while resumed. When version_code is 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: UpdateRepository emits UpdateState.NeedsInstallPermission once (the explanation-shown flag in prefs.preferences_pb, §14.2.2), and the next install() 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.
  • AC-16.6 "Check for update" on S-22 calls UpdateRepository.check(), which returns an UpdateCheck (D-092, revising D-073): UpToDate (nothing newer published; a 404 from E-5 counts as up to date) reports "You have the latest version" (settings_about_up_to_date, "Du har den senaste versionen"); Failed (no answer or another error) reports "Could not reach the server" (common_could_not_reach_server); Available shows the update banner. Debug builds report UpToDate without a request (AC-16.5).

FR-17 Splitwise import

  • AC-17.1 A one-off CLI kvitt-import (crate kvitt-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 other and are listed in the summary.
  • AC-17.4 Splitwise payments become settlements with one allocation.
  • AC-17.5 --dry-run prints 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 = import and 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) and values-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. The APK carries only these two languages: android/app/build.gradle.kts sets androidResources.localeFilters to en, sv (G-8; 2bd6b58), so libraries' translations into other languages are not packaged.
  • 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 /health returns 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 server service 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_at of 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_id is set by the server from the authenticated device's user when it first stores the row, never taken from the client. updated_by_user_id and updated_by_device_id are set by the server on every accepted write.
  • deleted_at marks 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, Rust i64, SQLite INTEGER).
  • 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 device targets 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. Against material3 1.4.0 only ShortNavigationBar of the Expressive set is public and used; every other Expressive API takes the fallback listed in D-064 (M6) 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 both variants through di/AppDataModule.kt in src/main (D-041, revised by D-081) 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 Compose Canvas in :core:design, no chart library (D-063). Vico 3.3.1 cannot give the per-bar TalkBack nodes and tap targets, 2 dp segment gaps, corner radii on the whole bar (10/4 at M6; top 4, bottom 2 dp since D-097) and an axis gutter growing at 200 % that docs/ux/design-spec.md §3.10 requires. Vico was removed from the version catalog on 2026-10-18 (a312758); it was never used The three charts are small; the design spec's accessibility rules need per-bar control
Push Firebase Cloud Messaging (firebase-messaging 26.0.0 through the Firebase BoM 35.0.2, google-services plugin 4.5.0; registration by Firebase Installation ID, D-083), 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, WorkManager's test WorkManager from androidx.work:work-testing (a testImplementation of :data, the same version as work-runtime, c366fbd). 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
App icon Adaptive launcher icon, vectors only (D-095): android/app/src/main/res/mipmap-anydpi/ic_launcher.xml with background colour #9C5238 (the light theme's primary, values/ic_launcher_colors.xml) and foreground and monochrome layers (drawable/ic_launcher_foreground.xml, drawable/ic_launcher_monochrome.xml) drawing the receipt_long app mark at 48 dp, centred in the 108 dp canvas; the manifest's android:icon and android:roundIcon point at it, which clears lint MissingApplicationIcon No raster assets; themed icons use the monochrome layer

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 Built by M6 (7730a31) from docs/ux/design-spec.md §2 and §3: KvittTheme, tokens (KvittColors, KvittShapes, KvittMotion, which holds only the seesaw spring; navigation has no motion, D-108), typography, shared components (money text, avatar, category tile and pickers, balance header, sync pill, banners, charts drawn with Canvas, D-063; 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, the shared failure texts (format/Failures.kt: FAILURE_NETWORK, commonFailureText), the shared limits (format/Limits.kt: BP_TOTAL, DISPLAY_NAME_MAX, DESCRIPTION_MAX, NOTES_MAX, FIRST_DATE, DAYS_AHEAD_MAX, §9.6) and savedSplitLabel (S-8, S-16), shared strings (D-118). Its test fixtures (src/testFixtures, package se.hagstedt.kvitt.design.testing) are the shared test support of every Android module (D-118); they are not a module :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, registration by Firebase Installation ID (D-083), maps messages to PushEvent (:core:model, model/Push.kt, moved there by M12e, D-095) through PushMapping; PushListener stays in the adapter :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. Golden layout (D-098, M8c, revises D-066): three goldens per component sheet or screen state, <name>_sv_light.png, <name>_sv_dark.png and <name>_en_light.png (English covers string length, dark covers the theme; there is no en_dark), plus <name>_sv_light_200.png at 200 % font scale for the long states; :app's empty_screen_<light|dark>.png are the only other names. The content is composed once and language, theme and font scale are switched through a configuration context, so Compose's non-linear font scaling still applies. Every capture renders at 1x density through the Robolectric qualifier w411dp-h<height>dp-mdpi, so a full screen of 411 × 891 dp is 411 × 891 px. Every committed PNG (goldens and the docs/ux/ mock-ups) is compressed losslessly with oxipng -o 4 --strip safe before committing (7c453d8); Roborazzi verifies against the compressed files. Feature modules follow the same layout: each feature module has Roborazzi screenshot tests of its screen states with goldens in src/test/screenshots/, rendered full screen. A screen harness renders one more frame and idles the main looper before each capture; without that :feature:home and :feature:account captured the previous variant, so their committed sv_dark (and some _sv_light_200 and dialog) goldens were one variant stale, fixed and re-recorded in dc7a296 and 5b2ed37. The reduction took the goldens from 1143 PNGs and 131 MB to 865 and 26 MB, the mock-ups from 36.8 to 26.0 MB, for a small repository on the home Gitea server. Since the Android overhaul (c582363, D-118) the harness is shared: :core:design's test fixtures (src/testFixtures, package se.hagstedt.kvitt.design.testing) hold Variant, TestClock, TestEnvironment, ScreenSnapshots with the capture modes Root, Screen and ScreenWhenIdle, MainDispatcherRule and withMain, consumed as testImplementation(testFixtures(project(":core:design"))). Test fixtures are not a module, so the module graph below is unchanged; the architecture check rejects a module's test fixtures in any configuration other than a test configuration. The convention plugin kvitt.android.screenshots wires Roborazzi (plugin, outputDir src/test/screenshots) and the test libraries; feature build scripts carry no screenshot block.

Hilt wiring (D-081, M8). Both variants bind every :core:model interface to :data: android/app/src/main/.../di/AppDataModule.kt includes DataBindingsModule (D-041), and AppBuildInfo comes from BuildConfig in both variants. The debug fakes module (a41f240) and the release-only data module were deleted by M8; no Hilt module lives in src/debug or src/release. Fakes live only in test source sets and feature previews and never reach an installed build; :app's unit tests run on a plain Application, not the Hilt application.

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 example onOpenExpense: (ExpenseId) -> Unit, onBack: () -> Unit).
  • Features never reference other features or the NavHost. :app owns the NavHost and connects the lambdas to routes.
  • ViewModels receive repositories through the :core:model interfaces (§14.2.4). :app binds the interfaces through Hilt to :data; bindings to :core:fakes exist only in test and debug-preview source sets and never reach a release build.
  • Shared UI that two features need is built in :core:design by M6 (D-019); a feature never copies another feature's component.
  • Strings: :app holds only the app name; :core:design holds the shared strings (including the notification copy and channel name of §13.3 and §14.6, which :app uses); each feature module holds its own. Lint MissingTranslation is an error in every module. Keys of a feature module are prefixed by the feature name (home_, account_, expense_, ...); keys in :core:design never 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 fourteen defaults of AC-7.2 (D-054), 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): the X-Kvitt-Protocol check (426 protocol_unsupported), the X-Kvitt-Server-Version response header and the §8.6 body limits (1 MiB, 5 MiB for PUT /me/picture) are layers in src/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 in src/api/rate_limit.rs and attached at one line in router.rs that M2a marks with a comment; that line is the only one M3 edits in router.rs.
  • Background tasks: M2a creates src/tasks/mod.rs with a start function that serve calls once after migrations (initially doing nothing). M4 owns src/tasks/** and starts the scheduler, the push sender and the sync_mutations pruning from it. As M4 built it (D-039): tasks::start returns the AppState with the real push queue installed, and serve builds the router from that returned state. Since the server overhaul (6c405e1) it is a synchronous fn start(AppState) -> AppState (it never awaited or failed); D-039 holds.
  • Audit and push interfaces: M2a defines the traits AuditWriter (in src/audit/) and PushQueue (in src/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::new holds SqlAuditWriter by default and NoopAuditWriter no longer exists; NoopPushQueue stays as the queue when push is disabled (§14.6), and tasks::start installs the bounded PushHub otherwise. As M2a built them (exact signatures in server/README.md):
    • AuditWriter::write takes the caller's open connection inside its BEGIN IMMEDIATE transaction and one AuditEvent (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 an AppError. The writer derives changes (§14.4.7) and takes the entry's server_version from that same transaction.
    • PushQueue::enqueue takes one PushMessage (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_date and the day clamping in kvitt-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 through AuditWriter, push through PushQueue) in src/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_version sequence. One integer cursor per phone is the simplest correct incremental sync. SQLite's single writer, with every write in a BEGIN IMMEDIATE transaction, 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 PackageInstaller is 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; S-22 shows its server_version and schema_version (D-110)
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 and revoked_at IS NULL, and loads the user. Missing or unknown token: 401 unauthorized. Revoked: 401 device_revoked.
  • The server updates devices.last_seen_at, devices.app_version_code and devices.app_version_name at most once per hour per device. That update sets app_version_code only when the request carries X-Kvitt-App-Version and its value parses as an integer, and app_version_name only when the request carries X-Kvitt-App-Version-Name with 1 to 32 characters after trimming (D-090); otherwise the stored value is kept. E-3 stores both from the accepting request.
  • 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 file auth.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 -, as 7K3M-QX9D-2HPA. URLs carry the 12 characters without hyphens.
  • Normalisation before hashing or sending: uppercase; remove - and spaces; replace O with 0 and I and L with 1. A normalised code that is not exactly 12 alphabet characters is invite_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 (android/app/src/main/res/xml/network_security_config.xml): 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.com only. The pinned-root config is in android/app/src/releasePinnedRoot/res, added to the release resources only when the Gradle property kvitt.tls.pinnedRoot is true (D-082).
  • Debug builds override the config in android/app/src/debug and allow cleartext to every host, because a network security config cannot express IP ranges; the AC-2.8 limit (localhost, 10.0.2.2, private LAN addresses) is enforced by SessionRepositoryImpl.normaliseServerUrl. Lint's InsecureBaseConfiguration warning on the debug config is intended (D-082).
  • The server trusts X-Forwarded-For only 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_limited with a Retry-After header in seconds.
  • Authenticated endpoints: at most 600 requests per minute per device.
  • Keying (D-032, revised by D-115): the per-device limit is keyed by the hash of the bearer token, with no database lookup. A token is accepted only when a response to it is 2xx. A request with a token not yet accepted counts against its client IP's unauthenticated limit before it runs and is refused with rate_limited once the IP is over; a 2xx gives that count back (a newly paired device costs its IP nothing); a 401 for an accepted token counts against the IP and withdraws the acceptance; token entries, accepted or not, are pruned once their window expires; every 401 counts against the IP exactly once (security review H-1: before, a random token answered 426 without X-Kvitt-Protocol became accepted for good, bypassed the IP limit and was never pruned). 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 of X-Forwarded-For. Per-IP keying needs the peer address: serve runs the router with into_make_service_with_connect_info::<SocketAddr>() (added by M4 in 15056dc, D-032 fulfilled).
  • GET /health from 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/picture at 5 MiB. Larger: 413 payload_too_large. Both limits are layers in router.rs (§6.3).
  • JSON strings are validated for length after trimming leading and trailing whitespace, and are stored trimmed, notes included (D-034). Control characters other than newline are refused with validation_failed in notes only; 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 prefix Kvitt.
  • Caddy's access log and its error log replace the /i/<code> path segment and every query string (the inviter's name in ?from=) with REDACTED through a request>uri filter; the Authorization header is redacted by Caddy's default (§10.5.2, D-117).
  • The push sender logs one info line push sent per message with aggregates only: push_type, devices_sent, devices_targeted and the last 8 characters of each device id that got it; the per-device line is at debug (D-113).
  • 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. Whether to encrypt them with an rclone crypt remote and narrow the token to the drive.file scope is an open owner decision (security review M-1, docs/validation/security-review.md).

8.8 Phone hardening

  • android:allowBackup="false" and android:dataExtractionRules excluding everything, so neither Google backup nor device transfer copies the token or database.
  • Exported components: only MainActivity (launcher and deep-link intent filters) and library receivers guarded by a permission (Firebase's, as Firebase requires). The FCM service KvittMessagingService is not exported. Checked on the merged release manifest at M8.
  • On device_revoked the 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, /data as the only writable volume, /secrets read-only, no Linux capabilities, no-new-privileges.
  • The caddy container runs as uid 10002 with supplementary group 1000, a read-only root filesystem, every capability dropped except NET_BIND_SERVICE, no-new-privileges and tmpfs /config and /tmp (§10.2, D-117).
  • 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 = 5000 on 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) = 0 always.
  • 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):

  1. Owing accounts = accounts where payer's net < 0. Sort by created_at ascending, then by id ascending.
  2. remaining = amount. For each owing account in order while remaining > 0: a = min(remaining, −net), emit allocation (account, a), remaining −= a.
  3. If remaining > 0 (the amount exceeds the total debt) and at least one owing account exists, add remaining to the last emitted allocation and report that account as over-settled.
  4. 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.
  5. 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 NULL and paused = 0, the candidate periods are every month p with start_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_id and updated_by_user_id = the template's created_by_user_id; updated_by_device_id null; created_at = updated_at = server now; recurring_template_id and recurring_period set), writes its splits (mode default resolves the account's current default ratio at generation time; other modes use the template's splits, with ratio/full recomputed by the ratio rule for the template amount), writes an audit entry (create, source = recurring), sets the template's last_generated_period and updated_at (server now), bumping its server_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_period to the latest period whose occurrence instant is before now, if that is later than the current value and at or after start_period. Resuming a template that has not started yet therefore keeps its start_period (D-033).
  • Changing day_of_month or start_period never regenerates past periods.
  • next_occurrence_date (in the wire form, computed with kvitt-domain::next_occurrence_date, §14.5.2) is the clamped date of the first candidate period after last_generated_period that is ≥ start_period and ≤ end_period; null when paused, deleted or past end_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
fcm_token (E-17) 1 to 512 characters after trimming, or null to clear; a Firebase Installation ID has 22 (D-114) invalid_request with field fcm_token
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). A stored amount opens for editing with both decimals ("1 247,60", "12 400,00"); ratio percent fields keep the trimmed form ("60", "60,5") (D-078; AmountInput.of and AmountInput.percent in :core:design).

9.7 Icon and colour tokens

Category icon tokens are Material Symbols names. The allowed set (exhaustive, 33 tokens; key, shield and account_balance added by D-054):

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, key, shield, account_balance.

Colour tokens (exhaustive, 14 tokens; slate and amber added by D-054) with the muted Kalas values of docs/ux/design-spec.md §2.3 (D-047, D-051; recorded in v0.9). The table holds the base colour of each token; design-spec.md §2.3 also gives each token's container (the colour over surface at 18 % in light, 24 % in dark) and the contrast ratios, all at least 3:1 as graphics. Category colours are never used for text.

Token Light Dark
green #4E7A55 #8FB896
orange #B26A3E #E8AA7E
brown #6B5244 #B8A090
yellow #9C7C2A #E6C67A
blue #4A78A8 #98C0E6
teal #367F82 #8CCAC8
purple #865891 #C59CDA
red #A84B45 #DC8F86
pink #AE5D7C #EFB0BE
grey #6C7177 #B0B2B3
indigo #45508D #969DD8
lime #73822F #B9CC86
slate #5E2D4E #B0719A
amber #937D63 #DFCFC0

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 before kr; comma decimal; always two decimals).
  • Money, any other locale: SEK 1,234.50 (U+00A0 after SEK; 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., English 3 Oct; with year when not the current year (3 okt. 2025, 3 Oct 2025). Detail views: Swedish lördag 3 oktober 2026, English Saturday, 3 October 2026. Timeline times: 24-hour 14:02 in 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".
  • Chart axis labels (months): Swedish three-letter forms without a dot (maj, jun, … okt), English May, Jun, … Oct; list dates keep the Swedish abbreviation dot (3 okt., 25 sep.; mars, maj, juni, juli are written out) (D-065).
  • Day ranges in captions and summaries (S-11 total caption, S-12 summary) are written out with the year: Swedish 24 augusti–17 oktober 2026, 1–30 november 2026, 28 december 2025–3 januari 2026 (the year once when both ends share it, the month once when both ends share it); the period chip and the S-12 title keep the short form 24 aug.–17 okt. (D-077, extends D-065; DateFormatter.dayRangeLong).
  • File sizes (the update banner, S-17): SI megabytes (10^6 bytes) with one decimal rounded half up and U+00A0 before MB: Swedish 8,4 MB, English 8.4 MB (D-065).
  • Month and weekday names in Swedish and English are tables inside DateFormatter, not platform locale data, so JVM tests and the phone give identical output (D-065).
  • 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" ("Kräver anslutning", common_needs_connection): create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update; "Sync now" ("Synka nu") on S-8 and S-10 (their missing states) and on S-16 (D-080). 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.

9.11 Category suggestion

The expense editor (S-7) selects the category from the description while the user types (D-049, owner 2026-10-18). The rule is a pure function in :domain (§14.5.6, with platform Unicode services through UnicodeText), fed by ExpenseRepository.suggestCategory in :data (§14.2.4) from Room and the keyword table contracts/category-keywords.csv (§14.8).

CS-1 Scope. The suggestion runs only in the editor for a new expense. It never runs when an existing expense is edited and never changes the category of a saved expense. The recurring template editor (S-19) does not use it in v1.

CS-2 Manual choice wins. The editor keeps a flag "category picked by hand", false when the editor opens. Picking any category in the category grid sets it to true, also when the user picks the category already shown. While the flag is true the suggestion never changes the category, for the rest of this editor session (until the editor closes). Nothing resets the flag.

CS-3 When it runs. While the flag is false, the category is recomputed on every change of the description and on every change of the account. It is a local computation over Room data; no network.

CS-4 Normalisation. Descriptions (typed and stored) and keywords are compared after this normalisation:

  1. Unicode NFC, then lowercase without locale rules (Kotlin lowercase(), which uses Locale.ROOT).
  2. Delete every ' (U+0027), ’ (U+2019) and : (so "Willy:s" becomes "willys" and "McDonald's" becomes "mcdonalds").
  3. Replace every remaining code point that is not a Unicode letter (general category L), a decimal digit (general category Nd) or & with a space. Whitespace is therefore replaced too.
  4. Collapse runs of spaces to one space and trim.

Diacritics are kept (å, ä, ö and é stay distinct; the keyword table lists both spellings where it matters). Digits are kept. The tokens are the normalised description split on spaces; the first token is the first of them. An empty normalised description matches no history and no keyword.

CS-5 Order. The suggested category is the first that applies of:

  1. History, exact: among the non-deleted expenses on the phone (every account, either user, pending, imported and recurring ones included) whose normalised description equals the normalised typed description, the category used most often; on a tie, the one used most recently.
  2. History, first token: among the non-deleted expenses whose first token equals the typed description's first token, the category used most often; on a tie, the one used most recently.
  3. Keywords: the category of the winning entry of the keyword table (CS-6).
  4. Default: the AC-8.6 fallback: the category last used in the selected account on this phone if still active, else other; when other is archived too, no category (AC-8.6).

"Used most often" counts the matching expenses per category. "Most recently" compares the latest matching expense of each tied category by (expense_date, then created_at), the expense-list order of AC-8.10; if that still ties, the lower category id (string order) wins. Archived and deleted categories are left out of the count; when no category is left, the next step applies (owner, 2026-10-18: most often, not most recent).

CS-6 Keyword table. Each entry has a keyword (already normalised per CS-4), a match and a default_key. An entry matches when:

  • token: some token equals the keyword;
  • prefix: some token starts with the keyword;
  • phrase: the keyword's tokens occur consecutively in the tokens.

When several entries match, the longest keyword (in code points, spaces included, a surrogate pair counting as one) wins; on equal length, the entry earlier in the table wins (the table is sorted by default_key, then keyword, both in code-point order). The winning entry gives the active category with that default_key (a renamed default included, since the key stays). If that category is archived, the next matching entry in the same order is tried; when none is left, step 4 applies. The table is fixed data shipped with the app; users do not edit it. No entry maps to other.

CS-7 What the user sees. The suggestion simply replaces the category shown in the Kategori field: no caption, no marker, no confirmation (owner, 2026-10-18: a wrong guess is corrected with one tap). Clearing the description returns the field to the step-4 default.

Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expenses with the fourteen categories of AC-7.2 and the §10.8 mapping put 79.7 % of rows in the right category, against 30.5 % for the step-4 default alone; with no history, the keywords cover 77 % of rows at 96 % accuracy. The replay is in docs/ux/category-suggestion.md.


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 infra/dev/secrets/ (git-ignored) read-only at /secrets, into which infra/dev/up.sh copies only fcm-service-account.json from infra/secrets/, so the server container sees no other secret (D-117; a folder rather than the file, so a missing key leaves the folder empty and push disabled, §10.4); its env file is infra/dev/.env.example (no RCLONE_REMOTE since fc6126c: the development backup never uploads); its server healthcheck is kvitt-server healthcheck; its backup runs as uid 10001 so that it can open the WAL files the server owns. The Pi's env template is infra/pi/.env.example (D-103). The development stack's database was migrated to schema 2 (migration 0002, §14.1.1) on 2026-10-18 after a backup, and to schema 3 (migration 0003, §14.1.2) on 2026-10-18 when it was rebuilt from b7b7483, again after a backup. On the Mac's Podman setup infra/dev/up.sh alone does not swap in a rebuilt image: run infra/dev/up.sh --down, then infra/dev/up.sh. Firebase (§10.7) is set up during development because push is needed then. Everything in this section that concerns the Pi (the Pi hosts and router of §10.1, §10.2, the registry and Pi deploy of §10.3, §10.5 TLS, publishing to the Pi in §10.6, the Drive upload of §10.9) is built by milestone M2c-pi, which runs after M8 and before M10.

No SSH from the Mac to the Pis (D-100, revises D-023). M2c-pi delivers a self-contained bundle in infra/pi/: the compose file, the Caddyfile, an env template, install, deploy, bootstrap and backup scripts and a README checklist. The owner copies the bundle to the Kvitt Pi, fills the placeholders in its .env (the OQ-3 values among them) and runs the scripts there. Agents never connect to the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive and need no allowance for them; they check the bundle on the Mac (docker compose config with the env template, shellcheck). Where §10.2 to §10.10 have a script on the Mac act on the Pi (for example infra/deploy.sh and infra/publish-apk.sh over SSH), that step becomes a bundle script the owner runs on the Pi; M2c-pi fixes the layout inside infra/pi/, the script names and how images and APKs reach the Pi. M2c-pi runs as the last step before M10, after the app is proven on the phone against the development server. D-001 stands: development stays on the Mac. The bundle landed on 2026-10-18 (2476667); its layout, scripts and paths are D-103 to D-106 and are written into §10.2 to §10.10 below; the owner's checklist is infra/README.md "Pi: what the owner does" (nine steps).

10.1 Hosts and network

Host Role Facts
Kvitt Pi Runs the compose stack Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin (from Docker's Debian arm64 apt repository, D-104), static LAN address. LAN address: a placeholder in the bundle's .env, filled by the owner (OQ-3, D-100); the Mac does not reach it over SSH
Home Assistant Pi Owns ports 80/443 and the Let's Encrypt certificate (certbot) for icehack.asuscomm.com Reached by cert-sync (on the Kvitt Pi) over SSH. Address, SSH port and user and certificate directory: placeholders in the bundle's .env (HA_HOST, HA_PORT, HA_USER, HA_CERT_DIR), filled by the owner (OQ-3, D-100)
Owner's M4 Mac Builds release APKs (infra/publish-apk.sh); builds images only as the fallback of the Gitea workflow (infra/build-images.sh, D-104); runs the development server (D-001) Apple Silicon, so linux/arm64 builds run natively in Docker
Router (ASUS) icehack.asuscomm.com DDNS Forwards external TCP 8443 to Kvitt Pi TCP 8443 (HUMAN step)
Container registry Holds Kvitt images A Gitea instance on another Pi at http://gitea.lan: plain HTTP, reachable only on the home Wi-Fi (D-001). Kvitt images live under the owner path gitea.lan/jacobh2 (D-104); Gitea Actions builds them (§10.3). If Gitea requires credentials for push or pull, the owner enters them where the bundle's checklist says (HUMAN, D-100); agents never hold them

The Kvitt Pi's compose stack never builds anything. It only pulls images and runs them.

10.2 Compose stack

File infra/pi/compose.yaml (compose project name kvitt), installed by infra/pi/install.sh to /opt/kvitt/compose.yaml on the Pi together with /opt/kvitt/Caddyfile and /opt/kvitt/.env (mode 0600, from infra/pi/.env.example) (D-103; was infra/compose.yaml and infra/env.example). Services:

Service Image Ports / volumes Memory limit Healthcheck
caddy official caddy image, pinned by version and digest: caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af (D-104) publishes 8443:8443; ./Caddyfile:/etc/caddy/Caddyfile:ro; certs:/certs:ro; caddy_data:/data 64 MiB wget -qO- http://localhost:2019/config/ >/dev/null
server ${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG} data:/data; /opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro; /opt/kvitt/import:/import:ro (empty except during the one-off import, §10.8, D-025); no published port (listens on 8080 internally) 160 MiB (budget: RSS under 100 MB in steady state) GET http://localhost:8080/health via the binary's own kvitt-server healthcheck subcommand (no curl in the image)
cert-sync ${KVITT_REGISTRY}/kvitt-cert-sync:${KVITT_TAG} certs:/certs; ./Caddyfile:/etc/caddy/Caddyfile:ro; /opt/kvitt/secrets/ha_ssh_key:/secrets/ha_ssh_key:ro; /opt/kvitt/secrets/ha_known_hosts:/secrets/known_hosts:ro 32 MiB both cert files exist and are non-empty
backup ${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG} data:/data; backups:/backups; /opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro 96 MiB last successful run marker newer than 26 hours, or no run yet since start
  • Named volumes: data (SQLite, pictures, APKs), certs, caddy_data, backups.
  • caddy depends_on cert-sync with condition: service_healthy and server with condition: service_started.
  • Every service: restart: unless-stopped, logging driver json-file with max-size: 10m, max-file: 3, TZ=Europe/Stockholm.
  • One internal bridge network kvitt.
  • The cert-sync container runs as uid 1000; its secret files on the Pi (/opt/kvitt/secrets/ha_ssh_key, ha_known_hosts) have mode 0600 and are owned by 1000:1000 (D-023). The backup container runs as uid 10001, the server's uid, on the Pi as on the development stack, because sqlite3 .backup on the WAL database must open the -shm file the server owns; so /opt/kvitt/secrets/rclone.conf has mode 0600 and is owned by 10001:10001 (D-105, revises D-023 for this file). rclone writes refreshed access tokens back to its config, and the mounted file is read-only, so backup.sh works on a temporary copy of it on every run (D-105). install.sh gives the named volumes their owners once (/data, /data/apk, /backups to 10001; /certs to 1000:1000 with mode 0750; Caddy's caddy_data to 10002, D-117, extending D-105). The FCM key keeps the mode of §10.7 (0440, root:10001).
  • The caddy container is non-root (D-117): user: "10002:10002" with group_add: ["1000"] (cert-sync's group, to read the 0640 files in the 0750 /certs), read_only: true, cap_drop: [ALL] and cap_add: [NET_BIND_SERVICE], no-new-privileges, tmpfs /config and /tmp. Port 8443 needs no capability, but the image's /usr/bin/caddy carries the file capability cap_net_bind_service=ep and the kernel refuses to exec it when that capability is not in the bounding set (checked 2026-10-18), so it is the one capability kept.

.env keys (exhaustive, D-103). Read by compose.yaml: KVITT_REGISTRY (gitea.lan/jacobh2), KVITT_TAG, HA_HOST, HA_PORT, HA_USER, HA_CERT_DIR, RCLONE_REMOTE (default gdrive:), RCLONE_FOLDER_ID. Read by install.sh only, never by the containers: KVITT_REGISTRY_USER, KVITT_REGISTRY_TOKEN (empty for anonymous pull). Informational, used in the owner's scp commands on the Mac and read by nothing on the Pi: KVITT_PI_HOST, KVITT_PI_SSH_USER. Unfilled values are CHANGE_ME placeholders; install.sh refuses a .env that still contains one.

10.3 Images, registry and build

  • Image names (D-104): ${KVITT_REGISTRY}/kvitt-server, ${KVITT_REGISTRY}/kvitt-cert-sync, ${KVITT_REGISTRY}/kvitt-backup, with KVITT_REGISTRY=gitea.lan/jacobh2 (was ${KVITT_REGISTRY}/kvitt/<name>).
  • Images are built by Gitea Actions (D-104): .gitea/workflows/build-images.yaml, following the owner's working example workflow. Runner label host (an act_runner on a Raspberry Pi 3 with 1 GB RAM; the builds run on that Pi itself), sh steps, Alpine apk add git docker-cli docker-cli-buildx, docker login gitea.lan as ${{ github.actor }} with the repository secret kvitt_cicd (created by the owner), a plain-HTTP buildx builder local-http-builder (buildkitd.toml with http = true for gitea.lan) created once and reused so its BuildKit cache survives, actions/checkout@v4 with fetch-depth: 0. The token reaches docker login through the environment, never expanded inside a shell line (security review L-8, e70fe0d); kvitt_cicd needs only the write:package scope, because the git tag push goes through the credentials actions/checkout stored (the job's own token) (D-117; if that push fails, adding write:repository is the owner's decision). Every buildx push passes --provenance=false and --metadata-file, and a step "Print image digests" prints each pushed image's digest, which must match what the Pi records (D-117). Triggers: a push touching server/**, infra/cert-sync/**, infra/backup/** or .gitea/workflows/**, and workflow_dispatch. Version v<MAJOR>.<MINOR>.<PATCH> with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest v1.0.* git tag (v1.0.0 when there is none); each image is tagged with it and latest, and the git tag is pushed after the build. That tag is what the deploy script takes; it says nothing about the APK version (§10.6). The Pi runner passes --build-arg CARGO_BUILD_JOBS=1 to the server build (Pi 3, 1 GB), and server/Dockerfile honours it (ARG CARGO_BUILD_JOBS in its build stage, passed to cargo as --jobs only when set; unset on the Mac means one job per core; b4d9467). The first push to origin (2026-10-18) triggered the first run; its result and duration are still to be reported by the owner. Unverified risk: the workflow pushes a git tag, and whether that push re-triggers the path-filtered workflow is not known.
  • Script infra/build-images.sh [tag] on the Mac is the fallback (D-104): builds the three images for linux/arm64, tag defaults to the short git SHA of HEAD, also tags latest; pushes when infra/secrets/registry.env (git-ignored: KVITT_REGISTRY, optional KVITT_REGISTRY_USER and KVITT_REGISTRY_TOKEN) exists, otherwise builds locally and prints the exact push commands. Refuses to run with a dirty working tree or while a Gradle build holds the tools/gradle-serial.sh lock (the lock check shared through tools/gradle-lock.sh, 2fac1ec). Works with Docker buildx and with Podman.
  • server image: multi-stage. Stage 1 rust:<version>-bookworm, where <version> is the exact Rust release pinned in server/rust-toolchain.toml (the current stable when M2a pins it; the two always change together), builds kvitt-server and kvitt-import in release mode with --locked for aarch64-unknown-linux-gnu (native on the M4; no target-cpu=native, the Pi 3 is a Cortex-A53). Stage 2 gcr.io/distroless/cc-debian12:nonroot (arm64) pinned by digest, with the two binaries in /usr/local/bin/, user 10001, entrypoint kvitt-server, command serve. The Dockerfile selects the Rust target from TARGETARCH: arm64 (aarch64-unknown-linux-gnu) is the shipped target; amd64 (x86_64-unknown-linux-gnu) also builds, emulated on the Mac, for local use only. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (infra/dev, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (docs/spikes/pi-image-arm64.md, landed 3e6c337): arm64 image 63.8 MB, cold build 48 s, CA bundle from distroless, binaries run natively; running on the Pi itself is M2c-pi's.
  • cert-sync image: alpine with openssh-client-default, curl, openssl (PEM and key checks, D-104) and tzdata, and the shell script infra/cert-sync/sync.sh, run once at start by infra/cert-sync/entrypoint.sh and then daily by supercronic.
  • backup image: alpine with sqlite, gzip, tar, rclone and tzdata, and infra/backup/backup.sh run by supercronic; the healthcheck is infra/backup/healthcheck.sh.
  • Both sidecars schedule with supercronic instead of busybox crond (D-104): they run as non-root users, and busybox crond started as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedules are unchanged (0 4 * * * cert-sync, 30 3 * * * backup).
  • Pinned versions (resolved 2026-10-18, D-104; table in infra/README.md "Versions"): Caddy caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af; Alpine alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 for both sidecars; cert-sync packages openssh-client-default 10.3_p1, curl 8.22.0, openssl 3.5.9, supercronic 0.2.49; backup packages supercronic 0.2.49 beside M2c-dev's sqlite, rclone and tar. Docker Engine on the Kvitt Pi comes from Docker's Debian arm64 apt repository (docs.docker.com: the Raspberry Pi OS page is for 32-bit only), docker-ce 5:29.8.2 with docker-ce-cli, containerd.io, docker-buildx-plugin and docker-compose-plugin.
  • Bundle scripts (D-103), run by the owner on the Kvitt Pi (D-100), with shared helpers in infra/pi/lib.sh:
    • infra/pi/install.sh, run from the copied bundle, idempotent: creates /opt/kvitt/{secrets,import,apk}, installs the secret files with the modes and owners of §10.2 and §10.7, adds gitea.lan to insecure-registries in /etc/docker/daemon.json (restarting Docker when that changed) and logs in to the registry when KVITT_REGISTRY_USER and KVITT_REGISTRY_TOKEN are set, copies compose.yaml, Caddyfile, .env and the scripts to /opt/kvitt, sets the volume owners, pulls, starts the stack and waits for it. A newer bundle is installed by copying it again and re-running install.sh; a KVITT_TAG that deploy.sh set in /opt/kvitt/.env is kept, so re-running an older bundle never rolls the server back to the bundle's tag (b647faf). It records the pulled image digests as deploy.sh does (D-117).
    • /opt/kvitt/deploy.sh <tag> [--skip-backup]: runs backup-now.sh first on every deploy, because the Pi cannot tell whether a tag carries a migration (skipped when no database exists yet, or with --skip-backup, for a rollback while Drive is down); keeps the current tag in /opt/kvitt/previous-tag for rollback; sets KVITT_TAG in /opt/kvitt/.env, runs docker compose pull, prints the registry digest of every image the stack runs and appends one line <UTC time> <KVITT_TAG> <image reference> <digest> per image to /opt/kvitt/deployed-digests, printing a WARNING when a reference recorded before now has a different digest (expected only for a moving tag such as latest; the registry is plain HTTP on the LAN, so a swapped image would otherwise go unnoticed, security review L-9, D-117), then docker compose up -d, waits for server to be healthy, prints GET /health.
    • /opt/kvitt/bootstrap.sh (first install: the admin invite of FR-1), /opt/kvitt/backup-now.sh (one backup run, succeeds only when last-success changed, §10.9), /opt/kvitt/install-apk.sh (§10.6).
  • The Pi's Docker daemon must trust gitea.lan as an insecure (plain HTTP) registry (insecure-registries). install.sh adds it with sudo, run by the owner (D-103).

10.4 Server configuration and CLI

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.jpg and -128.jpg; older versions are deleted after a successful upload. E-10 deletes the JPEGs and leaves an empty marker file <picture_version>-removed in pictures/<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.json and apk/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
invite-partner Creates a partner invite by the admin, as E-12 (D-113) 0 ok, 2 household full, 3 no admin
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-sync runs sync.sh at container start and daily at 04:00 Europe/Stockholm. It connects to ${HA_USER}@${HA_HOST}:${HA_PORT} with the key /secrets/ha_ssh_key and StrictHostKeyChecking=yes against /secrets/known_hosts, always requests the remote command tar -C ${HA_CERT_DIR} -cf - fullchain.pem privkey.pem and unpacks the tar stream into a temp directory (D-106).
  • The key is dedicated to Kvitt (infra/secrets/ha_ssh_key on the Mac, git-ignored). It is added restricted with restrict,command="tar -C <cert dir> -cf - fullchain.pem privkey.pem" (variant A, required; optionally from="<Kvitt Pi>" too). A plain key for root (the old variant B) is not acceptable (D-117, security review M-2): the add-ons log in as root, and the key would give an unrestricted root shell on the Home Assistant Pi. If the add-on refuses the restricted line, a dedicated non-root user that can only read HA_CERT_DIR (HA_USER) gets the plain key; if neither is possible, the fallback is §10.5.3. Because the script always requests that exact command, both forms work unchanged (D-106). Which form the add-on accepts is reported by the owner at checklist step 3 and recorded in infra/README.md.
  • If both files parse as PEM, the certificate's not-after is in the future, the private key matches the certificate, the certificate does not expire before the installed one, and the SHA-256 of either differs from /certs, the script moves them into /certs (mode 0640, group readable by Caddy) and reloads Caddy: curl -fsS -X POST http://caddy:2019/load -H 'Content-Type: text/caddyfile' -H 'Cache-Control: must-revalidate' --data-binary @/etc/caddy/Caddyfile. A key not matching its certificate, or a certificate expiring before the installed one, is refused and the installed pair is kept (D-106). At first start Caddy is not up yet (it waits for cert-sync to be healthy) and loads the files when it starts, so an unreachable Caddy admin API is logged and is not a failure (D-106).
  • Any failure logs one line and keeps the existing certificate. The healthcheck stays green while valid files exist.

10.5.2 Caddyfile

File infra/pi/Caddyfile (D-103), installed as /opt/kvitt/Caddyfile. As of 7fb86f5 (D-117) it reads:

# Kvitt Caddyfile (SPEC §10.5.2, D-117). Installed as /opt/kvitt/Caddyfile; cert-sync reloads it through the admin API.
{
	admin 0.0.0.0:2019
	auto_https off
	# Caddy's own error log (for example a 502 when the server is down) carries the request too: same URI filter.
	log default {
		output stderr
		format filter {
			request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED`
			wrap json
		}
	}
}

icehack.asuscomm.com:8443 {
	tls /certs/fullchain.pem /certs/privkey.pem
	request_body {
		max_size 6MB
	}
	# Set after the server's response headers (defer), so a header the server also sends appears once.
	header {
		defer
		Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
		X-Content-Type-Options nosniff
		X-Frame-Options DENY
		Referrer-Policy no-referrer
	}
	reverse_proxy server:8080
	# Access log: the invite code in /i/<code> and every query string (the inviter's name in ?from=) become
	# REDACTED (SPEC §8.7); the Authorization header is redacted by Caddy's default.
	log {
		output stdout
		format filter {
			request>uri regexp `^(/i/)[^?]+|([?]).+` `${1}${2}REDACTED`
			wrap json
		}
	}
}
  • Logs (D-117, security review L-1): the access log and Caddy's own error log (log default, which also carries the request, for example on a 502) pass request>uri through a regexp filter that turns /i/<code> into /i/REDACTED and every query string into ?REDACTED, so neither invite codes nor the inviter's name (?from=) reach a log (§8.7). Caddy logs must not include the Authorization header (Caddy redacts it by default). Verified on the Mac on 2026-10-18 with the pinned image: the header is logged as "Authorization":["REDACTED"] and the token never appears; TLS 1.1 is refused.
  • Response headers (D-117): Content-Security-Policy, X-Content-Type-Options, X-Frame-Options and Referrer-Policy as above, set with defer (after the server's own headers), so a header the server also sends (the landing page's Referrer-Policy: no-referrer, AC-2.5) appears once.

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 internal instead of the file pair.
  • The Caddy root certificate (/data/caddy/pki/authorities/local/root.crt in caddy_data) is copied once to android/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-config for icehack.asuscomm.com whose trust anchors are only @raw/kvitt_root_ca. Switching is a build property kvitt.tls.pinnedRoot=true in android/gradle.properties, which adds android/app/src/releasePinnedRoot/res to the release resources (D-082).
  • Caddy's internal root is valid 10 years; intermediates renew automatically.

10.6 APK hosting and release

  • version_code = git rev-list --count HEAD on the commit being released; version_name = YYYY.MM.DD-<short sha> of that commit (its commit date in Europe/Stockholm). Both are passed to Gradle as properties kvitt.versionCode and kvitt.versionName by the release script; debug builds use 1 and dev.

  • The release keystore is created once on the Mac (by the owner, or by an agent the owner allows; §11, D-024, D-101) at the root of the main checkout as kvitt-release.jks, with exactly:

    keytool -genkeypair -v -keystore kvitt-release.jks -alias kvitt -keyalg RSA -keysize 4096 -validity 10000
    

    The file is git-ignored by the *.jks pattern and never committed (D-101, revises D-024's "outside the repository"). android/local.properties holds its absolute path and secrets in the keys kvitt.release.storeFile, kvitt.release.storePassword, kvitt.release.keyAlias (kvitt), kvitt.release.keyPassword. Losing it means the phones must uninstall and reinstall (data comes back by sync). The owner keeps a copy of the file and its password in their password manager. Without these keys in local.properties, :app:assembleRelease builds an unsigned APK (M8). Release signing was verified on 2026-10-18: alias kvitt, RSA 4096, APK signature scheme v2.

  • Release builds are R8-shrunk, optimised and obfuscated (isMinifyEnabled, isShrinkResources, proguard-android-optimize.txt plus android/app/proguard-rules.pro, no project keep rules needed as of 2026-10-18; landed c30a49e); the signed APK is about 10 MB (10.0 MB at a0259ab; 43 MB before R8); infra/publish-apk.sh saves mapping.txt next to the APK as kvitt-<version_code>.mapping.txt for retrace; mappings are never served and stay outside the data volume (D-103).

  • infra/publish-apk.sh on the Mac (D-103): refuses a dirty working tree, builds :app:assembleRelease through tools/gradle-serial.sh with -Pkvitt.versionCode and -Pkvitt.versionName, refuses an unsigned APK, computes SHA-256 and size, and writes into infra/pi/out/apk/ (git-ignored) kvitt-<version_code>.apk, kvitt-<version_code>.mapping.txt and latest.json (written to a temp file, then renamed), keeping the newest 5 APKs with their mappings there. The owner copies the folder to /opt/kvitt/apk/ on the Pi (scp) and runs /opt/kvitt/install-apk.sh, which checks each SHA-256 against latest.json, copies the APKs into the data volume as apk/kvitt-<version_code>.apk and writes apk/latest.json last (temp file, rename), so the server never announces a file it does not have; the mapping files stay in /opt/kvitt/apk/. latest.json:

{
  "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.json on each GET /apk/latest (no cache) and serves GET /apk/files/{file_name} only for a file_name matching ^kvitt-[0-9]+\.apk$ that exists, with Content-Type: application/vnd.android.package-archive.
  • Older APKs stay on the Pi; install-apk.sh keeps the newest 5 in the data volume and deletes older ones (D-103).

10.7 Firebase setup (M2c-dev, with a HUMAN sign-in)

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.

  1. HUMAN: gcloud auth login on the Mac with the owner's Google account.
  2. gcloud config set project kvitt-1337; gcloud services enable fcm.googleapis.com firebase.googleapis.com.
  3. Add Firebase to the project (projects.addFirebase), skipped if it is already a Firebase project.
  4. Create two Android apps (projects.androidApps.create): display name "Kvitt", package se.hagstedt.kvitt; display name "Kvitt debug", package se.hagstedt.kvitt.debug.
  5. Fetch each app's config (projects.androidApps.getConfig) and merge them into one google-services.json holding both clients, written to infra/secrets/google-services.json in the main checkout (git-ignored). M8 copies it into android/app/google-services.json (git-ignored) with infra/dev/copy-google-services.sh <main checkout>, written by M2c-dev.
  6. gcloud iam service-accounts create kvitt-fcm --display-name "Kvitt FCM sender"; grant roles/firebasecloudmessaging.admin on the project; gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com.
  7. Copy the key to /opt/kvitt/secrets/fcm-service-account.json on the Pi (mode 0440, root:10001). Done by infra/pi/install.sh, run by the owner (D-103).
  8. If an organisation policy blocks service-account key creation, stop and report; do not change org policies.

The agent may write these git-ignored files under infra/secrets/ in the main checkout and act on the Google Cloud project kvitt-1337 for steps 2 to 6 (D-022); nothing else outside the repository.

State after M2c-dev (c885aba): steps 1 to 6 are done. Firebase is added to project kvitt-1337; Android apps exist for se.hagstedt.kvitt and se.hagstedt.kvitt.debug; the APIs firebase.googleapis.com, fcm.googleapis.com, cloudresourcemanager.googleapis.com and iam.googleapis.com are enabled; the service account is kvitt-fcm@kvitt-1337.iam.gserviceaccount.com with role roles/firebasecloudmessaging.admin (a role named roles/firebasemessaging.admin does not exist); infra/secrets/fcm-service-account.json and infra/secrets/google-services.json (both clients) exist in the main checkout. infra/README.md documents the REST calls for repeating or inspecting the setup. Step 7 is part of the owner's run of the M2c-pi bundle (checklist step 6).

The FCM free tier is sufficient; no billing account is required.

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: --db must name an existing file whose meta.schema_version is the version the server's migrations produce (3 since D-090; 2 from D-054), else it exits 1 (run kvitt-server migrate first) (D-046, D-054).
  • The whole run is one BEGIN IMMEDIATE transaction: find or create the account, skip rows whose import_key exists, 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-run is 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): 0 imported (or, with --dry-run, would import); 1 database or file error; 2 usage (bad arguments, --user not resolving to the two household users); 3 the file cannot be imported (header, invalid rows, non-SEK rows without --fx); 4 reconciliation mismatch or missing totals row. Nothing is written unless the status is 0 and --dry-run is 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 --user arguments; 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 Description is Total balance (English) or Totalsumma (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 --fx rate like any other value. A mismatch, or a file without a totals row, rolls the run back and exits 4; 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 --fx conversion 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.
  • Date is YYYY-MM-DD; Cost and 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, mode exact. Symmetric for B. If both nets are 0: payer is the owner, mode full on the owner (the owner pays and bears the whole cost, D-046). If net_A + net_B ≠ 0 or a resulting share is negative, the row is reported as invalid and the import aborts (nothing written).
  • Rows whose Category is Payment (English) or Betalning (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 Currency is not SEK abort the import unless a --fx <CUR>=<rate> is given for that currency; then every amount is converted with round_half_up(amount_ore × rate) per value, and for expenses the exact shares are recomputed as share_payer = C' − share_nonpayer'.
  • import_key = lowercase hex SHA-256 of splitwise|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 existing import_key columns of expenses and settlements (§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_id null; created_at = updated_at = the import time; one audit entry create with source = import; new server_versions from the global sequence; no pushes.
  • Description is truncated to 100 code points; the full original goes into notes when truncated. An empty description, or one over 1000 code points (the notes limit, §9.6), makes the row invalid (exit 3, nothing written) (D-046).
  • Category mapping (case-insensitive exact match on the Category column, English and Swedish names, D-003). The importer commits this table as the data file server/crates/kvitt-import/data/splitwise_categories.csv (columns splitwise_name,language,default_key) and reads it from there; it is the only mapping. Splitwise categories are mapped as finely as the fourteen categories of AC-7.2 allow (D-053); the Swedish names are those observed in the real Swedish export, each placed with its English Splitwise equivalent (D-044, D-053):
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
Rent, Mortgage Hyra, Avbetalning/Amortering housing
Electronics, Furniture, Household supplies, Maintenance, Services, Pets, Home - Other Elektronik, Möbler, Förbrukningsvaror, Skötsel/underhåll, Tjänster, Husdjur, Hem - Övrigt home
Electricity, Heat/gas, Water, Trash, TV/Phone/Internet, Cleaning, Utilities - Other El, Värme/gas, Vatten, Sophämtning, TV/telefon/internet, Städning, Verktyg - Övrigt utilities
Insurance Försäkringar insurance
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, Filmer, Musik, Sport, Underhållning - Övrigt entertainment
Medical expenses Sjukvård/medicin health
Childcare Barnomsorg children
Clothing, Gifts Kläder, Presenter shopping
Taxes Skatter fees
General, Education, Life - Other, Other Allmänt, Utbildning, Livet - Övrigt other
any other value any other value other, counted as unmapped

The Swedish names El, Värme/gas, Vatten, Sophämtning and Husdjur were not seen in the export; they are Splitwise's Swedish labels by analogy (D-053). A wrong guess only leaves such a row unmapped (other, counted), which the summary shows.

  • 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:

  1. Take a backup first: docker compose exec backup /backup.sh, and confirm both files on Drive (rclone lsf).
  2. Resolve the two production user ids by display name from the database (a read-only SELECT id, role, display_name FROM users through the backup container's sqlite3); the owner is the admin user. The ids go into the --user arguments (an exact, unique display name is also accepted, D-046).
  3. Copy the CSV files from the Mac to the Pi into /opt/kvitt/import/, which is bind-mounted read-only into server as /import (§10.2).
  4. docker compose stop server; for each file run docker 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 exit 0 with the reconciliation match (D-045); the target accounts must not exist yet or be empty; docker compose start server; check /health. (--entrypoint is needed because the image's entrypoint is kvitt-server, §10.3.)
  5. 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, or on the Pi as /opt/kvitt/backup-now.sh, which waits for last-success to change):

  1. sqlite3 /data/kvitt.sqlite ".backup /backups/staging/kvitt.sqlite" (online, consistent).
  2. sqlite3 /backups/staging/kvitt.sqlite "PRAGMA integrity_check" must print ok, else abort with a log line.
  3. gzip -9 to /backups/kvitt-<UTC yyyyMMddTHHmmssZ>.sqlite.gz; tar -czf /backups/pictures-<same stamp>.tar.gz -C /data pictures.
  4. For every pending kvitt-*.sqlite.gz and pictures-*.tar.gz in /backups (this run's pair and any leftovers of an earlier failed run): rclone copy <file> "${RCLONE_REMOTE}" --drive-root-folder-id "${RCLONE_FOLDER_ID}" --immutable (copy only; never sync, never delete, never overwrite), then confirm it by name and size with rclone lsf (D-106). rclone uses a temporary copy of the read-only rclone.conf (D-105).
  5. Only after every file is confirmed are the local copies deleted from the Pi's backups volume (D-002), and only then is /backups/last-success written with the stamp (D-106). A failed upload or confirmation keeps the local files and leaves last-success unchanged; the next good run uploads the leftovers. Nothing on Drive is ever deleted. When RCLONE_FOLDER_ID is empty (the development stack, D-001), step 4 is skipped with a log line, the local files are kept and last-success is written.

rclone setup (HUMAN, once, done 2026-10-18): the owner ran rclone authorize "drive" and signed in; the token (infra/secrets/rclone-drive-token.json) became infra/secrets/rclone.conf with a [gdrive] remote (type = drive, scope = drive, token), both git-ignored. The owner stages it with the bundle and install.sh installs it on the Pi (§10.2); the folder id stays in .env as RCLONE_FOLDER_ID, not in the conf. The Drive destination root folder id is 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV (OQ-4, answered); it is the value of RCLONE_FOLDER_ID in the Pi's .env.

Restore (documented step by step in infra/RESTORE.md; its commands run through the backup container, which has sqlite3, rclone, the Drive credentials and uid 10001):

  1. docker compose stop server.
  2. Download the chosen kvitt-*.sqlite.gz and pictures-*.tar.gz from Drive (rclone or browser) to the Pi.
  3. gunzip; sqlite3 <file> "PRAGMA integrity_check" must print ok.
  4. Move the current /data/kvitt.sqlite* aside (never delete), place the restored file as /data/kvitt.sqlite, extract pictures into /data/pictures.
  5. docker compose run --rm server rotate-epoch.
  6. docker compose start server; check /health.
  7. 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 10 MB each since R8 (§10.6), 5 kept.
  • Upgrades: the Gitea workflow builds and pushes a v1.0.N tag (or infra/build-images.sh on the Mac), then the owner runs /opt/kvitt/deploy.sh <tag> on the Pi. Migrations run on start; a failed migration exits non-zero, the container restarts in a loop, and the previous tag is redeployed with /opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup (migrations are forward-only, so the infra plan's runbook says to restore the pre-upgrade backup if a migration had partially applied; sqlx runs each migration in a transaction, so this should not happen).
  • Before every deploy, deploy.sh runs a backup and waits for last-success to update: the rule "back up before a deploy with a migration" is applied to every deploy, because the Pi cannot tell beforehand whether a tag carries one (D-103).
  • Who runs what (D-100): every command on the Pi in this section is run by the owner from the M2c-pi bundle's scripts and README checklist; the deploy script is infra/pi/deploy.sh, installed as /opt/kvitt/deploy.sh (D-103); the owner's operations table is in infra/README.md. Agents prepare and check the bundle on the Mac and never connect to the Pi.
  • Memory check: docker stats --no-stream after the release milestone; server RSS must be under 100 MB with both phones syncing.
  • Image digests: /opt/kvitt/deployed-digests holds one line per image and deploy (§10.3, D-117); a WARNING from deploy.sh or install.sh that a tag changed digest is investigated before the stack is trusted.
  • Client IPs (once, after the router forward, D-117): with the phone off Wi-Fi, open /health, then check that Caddy's access log shows the mobile network's public address in remote_ip, not a 172.* or 10.* bridge address; otherwise every internet client shares one per-IP limit of §8.6 (security review I-5; the command is in infra/README.md's operations table).

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

Category contract v2 (D-054), after SPEC v0.8:
M12a contracts + :domain suggestCategory (landed aa765bb) ─┬─► M12b server (migration 0002, importer table; landed 6eaccec)
                                                            └─► M12c android (:core:model, :domain, :data, fakes; landed b09f322) ─► M6, M7c
M12d UI contract additions (D-059: frequentCategoryIds, deletable; landed 557af7e) ─► M7a..M7f   (ran in parallel with M6, landed 7730a31)

After the M7 wave (v0.11):
M7a..M7f (landed) ─► M6b core:design fixes (landed b24c8b5) ─┬─► M8 app integration (landed 3d9eea8)
                                                            └─► M7g feature cleanups (landed 3dda414)

After M8 (v0.13):
M8, M7g (landed) ─► M12e contract pass (landed 8c60715 and b7b7483; D-086 to D-095, server schema 3)

After the first phone test (v0.15):
M8 phone test (2026-10-18) ─► M8b UI fixes (landed 328c466, D-097) ─► M2c-pi bundle (D-100), last, after the app is proven ─► M10

After v0.15 (v0.16):
M8b ─► M8c goldens and About (landed 75b549f, a0c25b0, 7c453d8; D-098, D-099) ─► M8d editor rows (landed bacbd18, D-102) ─► M8e (D-107)
R8 release build (landed c30a49e)

After v0.16 (v0.17):
M2c-pi bundle (landed 2476667, D-103 to D-106) ─► owner runs the bundle on the Kvitt Pi ─► M10
M8e S-19 option rows (landed a59abeb, D-107) ─► chips cleanup (landed 1328afe)
M8f instant navigation (landed 956908a, D-108)

After v0.17 (v0.18):
history rewrite and first push (18c527f) ─► infra overhaul (2fac1ec..e70fe0d) ─► server gap pass (landed 8660b7f, D-113)
security review (18c527f)   ─┬─► infra security (landed 7fb86f5, D-117)
AC-coverage audit (956908a) ─┼─► M12f Android follow-ups (landed dc7a296..a0259ab, D-109 to D-112, D-116)
                             └─► server overhaul (landed 8103dae..b985ce9; D-114, D-115) ─► router forward ─► M10

After v0.18 (v0.19):
M12f ─► Android overhaul (landed e4e6e5d..c582363, D-118) ─► push to origin (c582363) ─► owner runs the M2c-pi bundle ─► 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), as the last step once the app is proven on the phone against the development server (D-100). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: aa765bb, 6eaccec, b09f322). M12d (D-059) ran in parallel with M6 (landed 557af7e and 7730a31); M7a to M7f ran in parallel and landed on 2026-10-18 (1ea752c, 0e1d99d, 0b140aa, 7380e50, ea7d494, c4ab35f), followed by M6b, the :core:design fix pass (b24c8b5). M8 and M7g (feature cleanups) ran in parallel (disjoint paths: android/app/** and android/adapter/fcm/** against android/feature/**) and landed on 2026-10-18 (3dda414; cb832a7 and 3d9eea8). The contract pass M12e (D-086 to D-095) ran after them and landed on 2026-10-18 (8c60715 contract pass, b7b7483 launcher icon). The M2c-pi bundle (2476667, D-103 to D-106), M8e (a59abeb, D-107) and M8f (956908a, D-108) landed on 2026-10-18; what remains of M2c-pi is the owner running the bundle, and M10 waits for it.

11.2 Milestone table

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 Landed 6da6b1b (direction 3 "Kalas" muted picked, D-047). §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 Landed 6da6b1b, approved by the owner: docs/ux/design-spec.md (binding, §13.4), strings.csv (558 rows), screens/; UX-9 (D-059). §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, D-100) Bundle landed 2476667 2026-10-18 (D-103 to D-106); the owner's run on the Pi remains. A self-contained bundle in infra/pi/ that the owner copies to the Kvitt Pi and runs (D-100): compose stack, Caddyfile, env template with the OQ-3 values as placeholders, cert-sync image, install, deploy, bootstrap and backup scripts, a README checklist for the owner (Pi provisioning, router forwarding, trust of the gitea.lan registry, the cert-sync key on the Home Assistant SSH add-on, rclone authorize "drive"), infra/RESTORE.md, Drive backups to folder id 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV, Gitea Actions image build from the owner's example workflow in .gitea/workflows/, the /opt/kvitt/import bind mount (§10.2) On the Mac: docker compose -f infra/pi/compose.yaml config succeeds with the env template (D-103); shellcheck $(find infra -name '*.sh') clean (green at landing: shellcheck clean on 18 scripts, compose config valid, the cert-sync arm64 image builds; rehearsed on the Mac with Podman: cert-sync install, renew, skip and refuse cases, Caddy reload through the admin API, Authorization redacted in Caddy's log, TLS 1.1 refused, backup upload with rclone lsf confirmation and deletion after confirmation, retry after a failed upload, backup healthcheck red at 26 h, APK prune keeps 5; not verifiable from the Mac: the Home Assistant add-on's authorized_keys options, the Gitea workflow run, pulls from gitea.lan, the router forward, Drive, real Docker compose). On the Pi, run by the owner from the checklist: cert-sync fetched a valid certificate, https://icehack.asuscomm.com:8443/health answers from the phone on mobile data; one backup file appears in the Drive folder Copy the bundle to the Kvitt Pi and fill the placeholders in its .env (OQ-3); run the README checklist: install Docker if missing, add gitea.lan to insecure-registries, set the router forward 8443 → Kvitt Pi, add the cert-sync public key to the Home Assistant SSH add-on, rclone authorize "drive", provide the example Gitea Actions workflow and the Gitea credentials; answer yes/no per check. Done by 2026-10-18: the Drive token (rclone.conf), the example workflow, the Gitea secret kvitt_cicd, the cert-sync key infra/secrets/ha_ssh_key. Remaining: the owner runs the bundle, the nine steps of infra/README.md "Pi: what the owner does" (provision, Docker, the add-on key variant A or B, .env and secrets, copy and host key, install.sh, router forward, first backup and bootstrap, deploy). No allowances for agents on the Pis, Home Assistant, Gitea or Drive (D-100) M2c-dev, M8; runs last, after the app is proven (D-100)
M3 Server core API Landed 7869ec4 (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 15056dc (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 8f0e156 (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
M12a Category contract v2: contracts Landed aa765bb. contracts/category-keywords.csv (148 entries), contracts/schema/migrations/0002_categories_v2.sql (§14.1.1), contracts/test-vectors/suggest_category.json (65 cases), E-1 example schema_version 2, tools/contracts-check.py (keyword-table rules, 0002 loaded on a fresh and an in-use v1 database, Python reference of §9.11, new icon and colour tokens), and :domain's suggestCategory with UnicodeText passing the vectors (§14.5.6). The OpenAPI DefaultCategoryKey extension and the new category examples were held back for M12b python3 tools/contracts-check.py passes none M1, SPEC v0.8
M12b Category contract v2: server Landed 6eaccec (D-057, D-058). Migration server/crates/kvitt-server/migrations/0002_categories_v2.sql byte-identical to the contract file with its identity test; SCHEMA_VERSION 2; seed test of the fourteen defaults (ids, keys, icons, colours, sort orders, server_version from the sequence) on a fresh database and on a v1 database holding expenses (rows and references kept); icon and colour lists of §9.7 in validate.rs; DefaultCategoryKey with the fourteen keys in contracts/openapi.yaml (held back by M12a) and in the utoipa wire types, so the OpenAPI match test passes; the category examples M12a held back; importer table splitwise_categories.csv per §10.8 with its spec-parity test; importer schema check 2 (D-054); E-31 and other examples only where they enumerate the defaults Server gate; python3 tools/contracts-check.py passes none M12a, M3, M4, M9
M12c Category contract v2: Android Landed b09f322 (D-055 to D-057). :core:model: DefaultCategoryKey (14), ColorToken (SLATE, AMBER), CategoryIcons.ALL (33), ExpenseRepository.suggestCategory; :data: the UnicodeText binding (java.text.Normalizer, Character), suggestCategory from Room, DataStore and the keyword table copied from contracts/category-keywords.csv at build time (D-055; a test asserts byte identity and the §14.8 rules), parsed by :domain's parseCategoryKeywords (D-056); :core:fakes: fourteen defaults in the empty scenario and a fake suggestCategory; default-name string resources for the four new categories in English and Swedish (AC-7.2) where default names already live Android gate none M12a, M2b, M5
M12d UI contract additions (D-059) Landed 557af7e (D-060 to D-062). :core:model: ExpenseEditorOptions.frequentCategoryIds and CategoryRepository.deletable(id) (§14.2.4 "Changes in v0.9"); :data: both computed from Room, with repository tests; :core:fakes: both computed from each scenario's own data; ContractTest updated Android gate none M12c, M5
M6 Design system Landed 7730a31 (D-063 to D-066). Built from docs/ux/design-spec.md §2 and §3 and docs/ux/strings.csv. :core:design theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts (Compose Canvas, D-063), 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, M12c, the §9.7 colour change
M6b Design-system fixes after M7 Landed b24c8b5 (D-075 to D-079). :core:design fixes the M7 agents asked for: viewerPercentOf reads basis points, editor_split_full_on_you, AmountInput.of with both decimals and AmountInput.percent (D-078), quickRowCategories (D-075), two-line category labels with an ellipsis (D-076), DateFormatter.monthName and dayRangeLong (D-077), resource-backed snackbars in KvittScaffold, optional ConfirmDialog body, KvittButton loading form, InviteCodeField wrapping, EmptyState icon tile, AnalyticsFilterBar period row, screen tokens in KvittDimens, common_ok and common_undo, the module keys of M7b to M7f recorded in docs/ux/strings.csv (D-079) Android gate none M7a to M7f
M7a Feature: onboarding Landed 1ea752c 2026-10-18 (D-071). :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, M12d
M7b Feature: home and account Landed 0e1d99d 2026-10-18 (D-071). :feature:home, :feature:account screens S-4, S-5, S-6, S-16, S-17 as M7a none M6, M12d
M7c Feature: expense Landed 0b140aa 2026-10-18 (D-074, D-080). :feature:expense screens S-7, S-8, with the category suggestion of §9.11 (CS-2, CS-3, CS-7) as M7a none M6, M12c, M12d
M7d Feature: settle Landed 7380e50 2026-10-18 (D-074). :feature:settle screens S-9, S-10 as M7a none M6, M12d
M7e Feature: analytics Landed ea7d494 2026-10-18 (D-069 to D-071). :feature:analytics screens S-11, S-12 as M7a none M6, M12d
M7f Feature: settings Landed c4ab35f 2026-10-18 (D-071 to D-073). :feature:settings screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 as M7a none M6, M12d
M7g Feature cleanups Landed 3dda414 2026-10-18. Cleanups in android/feature/** after M6b; scope in its brief as M7a none M7a to M7f, M6b
M8 App integration Landed cb832a7 and 3d9eea8 2026-10-18 (D-081 to D-085). :app navigation and deep links (§13.2, D-071; each top-level screen draws its own bar), session gate (S-1 for Unpaired and Unrecognised, S-2, S-18 for Revoked, back on S-18 closes the app; Home shows S-3 while partner == null), deletion of the debug fakes module (both variants bind :data, D-081), :adapter:fcm with registration by Firebase Installation ID (D-083), push work and notification building (AC-13.5, D-085), invite links ignored while paired (D-084), ProvideHousehold as LocalKvittHousehold, LocalKvittClock from :data's Clock, LocalAvatarPictures handing the User to :data's Coil mapper, notification permission after the first successful sync after pairing (AC-2.9), update check in release only, revoke wipe, network security configs (§8.5, D-082), manifest hardening (§8.8), release signing and version properties (§10.6; unsigned without keystore keys), google-services.json copied with infra/dev/copy-google-services.sh Android gate (green at landing: BUILD SUCCESSFUL, 710 actionable tasks); on the owner's phone against the development server on the Mac (D-001): pair, add an expense offline, reconnect, see it synced, receive a push (run on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with kvitt-server pair-device; Home showed the account; the offline expense showed the pending marker and synced after reconnect, pending 0, row on the server; the device registered fcm_token and app_version_name; a push by fid arrived and its tap worked). Follow-up M8b (landed 328c466, D-097) fixed what the test showed: the invite-code field, the quick-row badge, the status-bar inset of KvittTopAppBar, the chart corners; FcmMessageBody.fid in :data followed (6eb3f85, D-096) Tap through the pairing, offline and push test as relayed by the orchestrator M3, M4, M5, M7a-M7f, M2c-dev (Firebase, development server)
M12e Contract pass Landed 8c60715 and b7b7483 2026-10-18 (D-086 to D-095). The §11.3 contract requests of M5 to M8 taken together on both sides: E-3 invite_kind and Pairing (D-086); the admin's name in E-12 links, passed on by E-4 (D-087); SyncSummary.lastAttemptFailed (D-088); Rejection.actor (D-089); X-Kvitt-App-Version-Name, devices.app_version_name by migration 0003, server schema 3 (D-090); NewRecurringTemplate (D-091); UpdateCheck (D-092); SettleState.untouched (D-093); the D-027 re-stamp in :data (D-094); PushEvent in :core:model, snackbar hosts in six screens, snackbar resource ids from S-7 and S-8, the adaptive launcher icon (D-095). Goldens re-recorded: S-20 devices (version name); new s1_pairing_link_admin and s22_about_check_failed Both gates (green at landing: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; server 253 tests, the OpenAPI equality, vector and migration byte-identity tests included) none (the development server must be rebuilt before the new APK is installed, §14.8) M8, M7g, SPEC v0.12
M12f Audit and security follow-ups (Android) Landed dc7a296, 5b2ed37, 2bd6b58, c366fbd, a0259ab 2026-10-18 (D-109 to D-112, D-116; no prompt file, orchestrator brief). S-16 read-only lost version (Rejection.lost, D-109); S-22 versions from /health (AboutState.schemaVersion, D-110); SettlementRepository without throws (D-111); E-17 retry (D-112); invite links dropped while paired (AC-2.6); private notifications (AC-13.9, D-116); localeFilters en, sv (AC-19.1); :data tests for the 429 retry, D-040 refusals and WorkManager unique work (work-testing, §6.2); the :feature:home and :feature:account screenshot harness fix (§6.2); the 19 string keys R8 found unused removed (strings.csv 562 rows, §13.4) Android gate (green at landing, 941 tests); release APK 10.0 MB Look at S-16 and S-22 on the next install (HANDOFF §3) M8f, the AC-coverage audit and the security review (§11.4)
M9 Splitwise importer Landed 50339ae (D-044 to D-046). kvitt-import per §10.8 with tests over synthetic CSV fixtures (crates/kvitt-import/tests/import.rs), reconciliation against the totals rows, the committed category table Server gate Provide the CSV files (kept outside git) when OQ-2 is answered M2a, M4
M10 First release Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check docs/validation/first-release.md and docs/validation/restore-drill.md complete; server RSS under 100 MB Create the release keystore with the keytool command of §10.6 at the repository root, or allow an agent to (D-101), and keep a copy and the password in the password manager; run the Pi steps from the M2c-pi bundle (D-100); install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive M2c-pi (the owner's run of the bundle), M3, M4, M8
M11 Import history Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044). Dry run of 2026-10-18 with the M12b mapping (D-053): Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. Real import run on the Pi per §10.8 "Production run" (D-025), docs/validation/splitwise-import.md with per-account totals compared to Splitwise's balances (aggregates only) Imported balances equal Splitwise's "Total balance" rows per group Provide the CSVs; confirm the balances on the phone match Splitwise M9, M10

11.3 Notes per milestone

  • 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.yaml only representationally (key order, $ref naming, 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.toml pins the exact current stable Rust release and the Dockerfile uses the same version (§10.3).

  • M2b writes the *State classes 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 how resolveRange signals an invalid CUSTOM range (§14.5.4, D-018: IncompleteCustomRangeException, D-011). Its screenshot-test choice is D-010 (Roborazzi). It also created the Room 3 entities and KvittDatabase v1 in :data (D-012), so M5 builds on them.

  • M5 owns :data and builds on the entities, KvittDatabase v1 and the exported schema android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json that M2b created (D-012); it adds DAOs, the API client, sync and the repositories. Room 3 migrations take an SQLiteConnection (§14.2.3).

  • M7x features use :core:fakes only; they never depend on :data. Each feature agent adds its fake scenarios in android/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). All six landed on 2026-10-18 (1ea752c M7a, ea7d494 M7e, 7380e50 M7d, 0e1d99d M7b, 0b140aa M7c, c4ab35f M7f); their readings are D-069 to D-074 and D-080, the shared fixes they asked for went into M6b (b24c8b5, D-075 to D-079). M7g (landed 3dda414) did the feature-side cleanups after M6b.

  • M6b (landed b24c8b5) is the :core:design fix pass after the M7 wave, in M6's paths (§14.7); see the §11.2 row.

  • M12e (landed 8c60715 and b7b7483 on 2026-10-18, D-086 to D-095) took the contract requests that M5, M6, M7 and M8 had collected here, as one contract change on both sides (§14.8): the invite kind from pair() (D-086), the admin's name in invite links (D-087), SyncSummary.lastAttemptFailed (D-088), Rejection.actor (D-089), the device version name with server schema 3 (D-090), NewRecurringTemplate without an id (D-091), UpdateCheck (D-092), SettleState.untouched (D-093), the D-027 re-stamp in :data (D-094), and PushEvent in :core:model, the six screen-hosted snackbar hosts, snackbar resource ids from S-7 and S-8 and the launcher icon (D-095). The 128 px picture size was left unchanged by assumption: :data keeps fetching E-11 at 512 and no size parameter was added, since v1 does not need one (the owner confirmed on 2026-10-18 that it is not needed: single 512 size, §14.2.4). Room stays at schema 1 (the phone stores no devices). A phone running the M12e APK needs a server at schema 3 (§14.8 "Compatibility").

  • Remaining contract requests (none is a contract change in v0.14; it needs the §14.8 rule when taken):

    • None open. S-22's "Licenser för öppen källkod" did nothing (no licences screen and no strings for one); the owner answered OQ-5 on 2026-10-18: no licences screen in v1, the row is removed (implemented by M8c, a0c25b0, D-099).

    The fid request was taken on 2026-10-18 (f56dcbe, D-096): the server sends the installation id in FCM HTTP v1 message.fid (§14.6).

  • M2c-dev / M2c-pi (D-001, D-100): M2c-dev builds only what development needs on the Mac (landed); M2c-pi builds everything that concerns the Pi (registry trust, TLS, router, cert-sync, Pi deploy, APK publishing to the Pi, Drive upload, Gitea Actions) as the infra/pi/ bundle the owner runs, and runs last, after M8 and the phone test, before M10.

  • M4 (landed 15056dc) added into_make_service_with_connect_info::<SocketAddr>() to serve so the per-IP rate limits of §8.6 see the peer address (D-032). Its tests are three binaries, tests/sync_api/, tests/push_fcm/ and tests/scheduler_daily/ (§14.7); the scheduler lives in src/tasks/scheduler.rs.

  • M8 (landed cb832a7 and 3d9eea8, D-081 to D-085) is the only plan that edits :app after M2b. As built: both variants bind :data through di/AppDataModule.kt, which includes DataBindingsModule; the debug fakes module is deleted (D-081, revising D-041). The session gate shows S-1 for Unpaired and Unrecognised, S-2, and S-18 for Revoked (back on S-18 closes the app); Home shows S-3 while partner == null; each top-level screen draws its own bar (D-071). M6's :app requests are met: ProvideHousehold is implemented as LocalKvittHousehold, LocalKvittClock is provided from :data's Clock, and LocalAvatarPictures passes the User model to :data's Coil mapper. M5's requests are met: AppBuildInfo from BuildConfig; WorkManager with KvittWorkerFactory and the default initializer disabled; SyncTriggers.onAppStart on process start and onForeground on later returns; push handled by an expedited WorkManager job calling syncForPush (D-085); UpdateRepository.check on every start and resume, in release builds only (AC-16.5); Coil's singleton is :data's ImageLoader. The notification permission is asked after the first successful sync after pairing (AC-2.9). Network security configs per §8.5 (D-082); the merged release manifest exports only MainActivity and permission-guarded library receivers (§8.8); :app:assembleRelease builds unsigned when local.properties has no keystore keys (§10.6); FCM registration by Firebase Installation ID (D-083); invite links ignored while paired (D-084). The end-to-end test ran on 2026-10-18 on the owner's S26 against the development server: pairing with the bootstrap code OK; a scripted partner joined through a second admin device paired with kvitt-server pair-device (the CLI had no partner-invite subcommand then; kvitt-server invite-partner followed in 8660b7f, D-113); Home showed the account; an expense added offline showed the pending marker and synced after reconnect (pending 0, the server has the row); the owner's device registered fcm_token and app_version_name; a push by fid was delivered and the notification tap worked. M8b (landed 328c466) fixed what the owner saw: the invite-code field holds the raw code and draws the hyphens as a VisualTransformation with an exact offset mapping (cursor bug fixed, unit test InviteCodeFieldTest); the quick-row and grid selected badge is drawn above the ripple clip (it was cut off); KvittTopAppBar pads for the status-bar inset through a windowInsets parameter and KvittScaffold follows; chart bar corners top 4, bottom 2 dp (D-097); 221 goldens re-recorded. FcmMessageBody.fid in :data followed in 6eb3f85 (D-096). Still unverified on the phone: the status-bar fix (next install) and the design-spec §2.7 typeface check (the owner looked and had no complaint). Its contract requests were taken by M12e (D-086 to D-095); the fid field followed in f56dcbe (D-096). M8e (landed a59abeb, D-107) turned S-19's account, payer and split chips into EditorOptionRows; the unused AccountChip, PayerChip and SplitChip were removed in 1328afe. M8f (landed 956908a, D-108) made navigation instant (NavTransitions in :app).

  • M10: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 at the repository root and referenced from android/local.properties (D-024, D-101).

  • M2c-pi (D-100, revises D-023): no SSH from the Mac to the Pis and no allowances for agents on the Kvitt Pi, the Home Assistant Pi, Gitea or Google Drive. The agent writes and checks the infra/pi/ bundle on the Mac; the owner copies it to the Kvitt Pi, fills the .env placeholders (OQ-3) and runs the README checklist, answering yes/no per check. It runs as the last step before M10, after the app is proven on the phone. The bundle landed 2476667 on 2026-10-18 (D-103 to D-106: layout and scripts, image names and the Gitea workflow, the backup uid, the cert-sync and backup refinements); the remaining part is HUMAN, the owner running the bundle, and M10 depends on it.

  • M9 (landed 50339ae) reuses the server's own push validation (kvitt_server::api::validate, rows) and the SqlAuditWriter, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary, crates/kvitt-import/tests/import.rs, over synthetic fixtures only; one test checks the §10.8 category table against data/splitwise_categories.csv.

  • M12a, M12b, M12c (D-049 to D-054) carry the owner's UX-session decisions into the contracts and the landed code: M3, M5 and M9 were built with ten defaults, so this is a contract change against main, not only the spec. M12a (landed aa765bb) wrote contracts/, the checker and :domain's suggestCategory; M12b (landed 6eaccec) and M12c (landed b09f322) ran in parallel (disjoint paths, §14.7); their outcomes are D-055 to D-058. The Room schema stays at version 1: category rows reach the phone through sync. The colour hex values of slate and amber are not needed by M12 (:core:model holds token names only); they arrive with U2 and M6.

  • M12d (D-059, landed 557af7e) added the two UI-contract members UX-9 found (ExpenseEditorOptions.frequentCategoryIds, CategoryRepository.deletable) to :core:model, :data and :core:fakes, in parallel with M6 (disjoint paths); its readings are D-060 to D-062 (§14.2.4 "Readings fixed in v0.10"). M7c and M7f consume them; the design spec's fallbacks (docs/ux/design-spec.md §3.7 and §6) are no longer needed.

  • M6 (landed 7730a31) built :core:design with charts on Compose Canvas (D-063), the Material 3 Expressive fallbacks of D-064, the formatting tables of D-065 and the golden layout of D-066. Its contract requests for M8 (no change now): :app provides LocalAvatarPictures with an AvatarPictures that builds the authenticated E-11 Coil request from the user id and pictureVersion (§14.3.4); :app wraps every screen in ProvideHousehold(me, partner); :app provides LocalKvittClock (tests and previews pass a fixed clock); SyncSummary gains a "last attempt failed" field so SyncStatusPill can drop its failed parameter (§13.3 S-4); done by M12e: SyncSummary.lastAttemptFailed, and SyncStatusPill, syncPillVariant and syncLineText no longer take failed (D-088). The design-spec §2.7 open point (that FontFamily.Default keeps tabular figures and distinct weights 500/600/700 under every One UI font style on the S26) is checked at the M8 install on the owner's phone; on 2026-10-18 the owner looked and had no complaint (not checked under every One UI font style).

  • M12f (landed dc7a296 to a0259ab, D-109 to D-112, D-116) took the Android findings of the AC-coverage audit and the security review (§11.4); see the §11.2 row. The S-1 confirm step for an unusual server (security review L-6) was not built: it needs a design state (OQ-6).

  • Server gap pass (landed 8660b7f, D-113): kvitt-server invite-partner (§10.4), which closes the ops request of the M8 test; tests for invite-partner, migrate (schema at SCHEMA_VERSION, idempotent), healthcheck (200, 503, nothing listening) and the SIGTERM drain of serve; the push sender's push sent info line (§8.7); tools/contracts-check.py follows invite_kind (D-086), app_version_name (D-090) and fid (D-096), 134 checks pass.

  • Server overhaul (landed 8103dae to b985ce9, D-114, D-115): shared stored-row reads and inserts (src/api/rows.rs, src/sync/rows.rs), shared invite creation and CLI plumbing, one test harness (tests/common/, §14.7), clippy pedantic wins, synchronous tasks::start (§6.3), not_implemented no longer produced (§14.3.2); the security fixes H-1 (D-115), M-3 and L-4 (D-114). Gate at landing: 268 server tests; all crates at their latest versions.

  • Infra overhaul (landed 2fac1ec to e70fe0d, no decision): one Gradle lock helper tools/gradle-lock.sh for tools/gradle-serial.sh, tools/clean-builds.sh and infra/build-images.sh; the pre-commit hook runs the Android tests through tools/gradle-serial.sh with JDK 25 on PATH, JAVA_HOME untouched; install.sh keeps the deployed KVITT_TAG (§10.3); --provenance=false on buildx pushes and the workflow token through the environment (§10.3); RCLONE_REMOTE dropped from the development env (§10 intro). Infra security (landed 7fb86f5, D-117): Caddy log redaction and response headers (§8.7, §10.5.2), the non-root Caddy container (§8.9, §10.2), the development stack's single secret (§10 intro), the digest record (§10.3, §10.10), kvitt_cicd with write:package only (§10.3), the restricted cert-sync key (§10.5.1).

  • Overhaul outcomes (2026-10-18). Server: landed through b985ce9 (see the server overhaul above). Infra: landed through e70fe0d (see the infra overhaul above). Android overhaul (landed e4e6e5d to c582363, D-118, no prompt file, orchestrator brief): one screenshot and test harness in :core:design's test fixtures and the convention plugin kvitt.android.screenshots (§6.2); the shared failure texts, limits and savedSplitLabel in :core:design (§6.2); dead code removed (KvittMotion down to the seesaw spring, KvittIcon.Description, unused dimens and shapes, catalogue and fake samples); the catalog entries roborazzi-junit-rule, roborazzi-compose, androidx-hilt-navigation-compose and androidx-lifecycle-viewmodel-compose removed; one CopyCategoryKeywords task in build-logic shared by :data and :core:fakes (D-055 holds); one configureAndroidModule for the application and library plugins; S-7's state types in ExpenseEditorState.kt. Gate at landing: 941 tests, no golden changed; release APK 10.46 MB. Pushed to origin at c582363.

  • M11 runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled.

11.4 Validation reports

Reports in docs/validation/ are deliverables (CLAUDE.md); they carry aggregates only and record the commit they were taken at. The release reports first-release.md and restore-drill.md (M10, AC-18.2) are still to be written.

  • docs/validation/ac-coverage.md, the AC-coverage audit taken at 956908a (ed1b130 before the history rewrite; SPEC v0.16), read-only: 270 rows, 260 Done, 5 Partial, 1 Missing, 4 not verifiable without a device. Verdict: feature complete in code; not yet complete against §4's acceptance rule. Its two mismatches in shipped behaviour are fixed by M12f: S-16 opens the lost change read-only (D-109) and S-22 reads the server version from /health (D-110); M12f also added tests for the §9.2 effect cases, AC-2.9, the 429 retry, D-040 refusals and WorkManager unique work. Still open: the AC-18.2 restore drill (Missing), AC-18.1 and AC-20.1 observed on the Pi, AC-16.3 the real PackageInstaller install (all M2c-pi or M10), and a KvittWorkerFactory test.
  • docs/validation/security-review.md, the security review taken at 18c527f (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (e70fe0d); H-1 (the in-memory rate limiter, 725901c), M-3 (picture decoding memory, baa5806) and L-4 (fcm_token length, b985ce9) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets forbidden rather than not_found for another user's device) is left as the spec says, decision pending with the owner; M-1 (backup encryption) and L-3 (APK download rate) wait for the owner. H-1 must be fixed before port 8443 is opened to the internet.

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 from the owner's example workflow (provided; .gitea/workflows/build-images.yaml, D-104). M2c-pi answered
OQ-2 Answered (D-003): two exports, English and Swedish header variants, member columns in either order, ISO dates, point decimal, double-quoted fields, trailing empty line and Total balance / Totalsumma row, payment category Payment / Betalning, all rows SEK. Group "hemma" becomes account "Hemma", the general group becomes account "Vardag". Details §10.8 and PLAN-M9. M9, M11 answered
OQ-3 Answered by placeholders (D-100): the LAN address of the Kvitt Pi; LAN address, SSH port and user of the Home Assistant Pi; the directory holding fullchain.pem and privkey.pem there (expected /ssl on Home Assistant OS); which SSH add-on is used. The owner fills them in the M2c-pi bundle's .env on the Pi; no agent needs them. M2c-pi answered
OQ-4 Answered: the Google Drive backup folder id is 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV (§10.9). M2c-pi answered
OQ-5 Answered 2026-10-18 and implemented (D-099, a0c25b0): no licences screen in v1; S-22 has no "Licenser för öppen källkod" row. M10 answered
OQ-6 Should S-1 ask for a confirmation before pairing when an invite link prefilled a server other than https://icehack.asuscomm.com:8443 (security review L-6)? It needs a design state for S-1 (copy and layout) and strings. M10 Not in v1: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116)

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, D-048)

  • Destination changes are instant, with no transition animation (D-108); motion stays inside screens (design-spec §2.11).
  • Bottom navigation with three destinations: Home (Översikt), Analytics (Statistik), Settings (Inställningar). An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth destination and not taken for v1 (D-048).
  • A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail.
  • Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back.
  • The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room).
  • Wiring (D-071, as built by M7a to M7f and connected by M8): the top-level screens S-3, S-4, S-11 and S-13 render their own KvittNavigationBar and take onNavigate: (KvittDestination) -> Unit; the :app scaffold draws no second bar, and the NavHost switches top-level destinations from those lambdas. Navigation-argument keys are constants in each feature (ARG_EXPENSE_ID and ARG_ACCOUNT_ID; SettleArgs.SETTLEMENT_ID and SettleArgs.ACCOUNT_ID; DrillDownArgs.KEYS; settings "categoryId" and "templateId"), read through SavedStateHandle.
  • Result snackbars (D-071): the result of S-7 ("Utgiften sparades"), of S-9 and of archiving an account is shown by the screen returned to; the account-archived snackbar has an undo action (common_undo, "Ångra"). As built by M12e (D-095): six screens host :app's shared snackbarHostState in their own scaffold (Home, AccountDetail, ExpenseDetail, SettlementDetail, DrillDown, SyncStatus), so a snackbar never overlaps a FAB or the bottom bar; :app has no overlay host. Features hand :app string resource ids, never resolved strings: ExpenseEditorScreen.onSaved(ExpenseId, @StringRes Int?), and onDeleted() on S-7 and S-8 takes no argument, :app then shows snackbar_expense_deleted.
  • Session gate (as built by M8): S-1 for an unpaired or unrecognised session, S-2 for the profile step after a partner pairing only (AC-2.10, D-086; a member pairing a second phone with a device invite goes to Home), S-18 for a revoked device (back on S-18 closes the app). Home shows S-3 while the household has no partner (partner == null).

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); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from (onboarding_code_help with the admin's name when the invite link carries from, else the generic help line onboarding_code_help_generic; PairingScreen(initialAdminName) and PairingViewModel.prefill carry the name from :app's AppLink.Invite, and a pasted link fills it too, D-087); progress while pairing; errors invite_invalid, network error, rate_limited Pair; paste code
S-2 Partner profile step Display name field (prefilled; a blank name shows onboarding_error_name_required "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a partner pairing (Pairing.inviteKind, D-086) Continue
S-3 Waiting for partner Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") 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 ("Sync failed" when SyncSummary.lastAttemptFailed; SyncStatusPill takes no failed parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); 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 with names (ratios written "60/40", common_ratio), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label ledger_settlement_label "Betalning" from :core:design, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row Add expense (not on an archived account); 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; the discard dialog has a title and no body text (D-080) 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; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); account, date, payer and split shown as four fixed full-width rows in that order, which do not move when a value changes (D-102); 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; after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071), onSaved handing :app the string resource id (D-095); the quick row is filled by quickRowCategories (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) 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; "missing" state (not yet on this phone) with "Synka nu" (expense_detail_sync_now), online only (§9.9, D-080); after a delete :app shows snackbar_expense_deleted on the screen returned to (D-095) 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, which keeps the direction swap (§9.4: per-account settle-ups go either way) and an editable amount (AC-11.4 "defaults to") (D-052); when editing, the saved rows count as edited and "Föreslå igen" re-proposes; the info line on untouched accounts reads SettleState.untouched (nets without the settlement being edited, empty for a per-account settle-up, D-093, revising D-074's reading of Home balances); the discard dialog has no body text (D-080) Confirm; Cancel S-10 Settlement detail Payer, recipient, amount, date, note, allocations per account; timeline; "missing" state with "Synka nu" (settle_detail_sync_now), online only (§9.9, D-080) 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"); a chart whose range has no expenses shows only the 0 baseline, with no gridline values (D-068). Total card (D-069): caption " · Hushållet" for the household measure and " · N veckor" in week granularity; for my share the label is "Totalt · Min del" so the caption does not repeat it; month presets show whole-month ranges ("maj–okt 2026"), custom ranges and week granularity show days (§9.8, D-077). Legend and stacking order: categories by sortOrder, accounts in AccountRepository.all() order (AnalyticsState.groups order is unspecified, §14.2.4). Loading means SyncSummary.lastSuccessAt == null and shows skeleton cards; offline looks like loaded; no error state (analytics are local, AC-14.1) 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"; a week as in docs/ux/strings.csv, "Vardag, vecka 41"; a category or account over the whole range ", ") (D-070); total; expense rows as in S-5. No loading and no error state (D-070) 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 (33 icons of §9.7), colour swatches (14 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 (the shared ledger rows of :core:design, D-019, not a separate 36 dp row); "Changes not synced" list from sync_rejections with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync failed" when online and SyncSummary.lastAttemptFailed (D-088); the stale-change copy names Rejection.actor, the partner when it is null (D-089); "Sync now", online only (§9.9, D-080); "Öppna" on a rejection shows the phone's lost version read-only (D-109): a dialog "Din version" (home_sync_lost_title, body home_sync_lost_body) with the ledger row it would have been (read-only: the row's onClick is null), the account, payer, split and notes (Rejection.lost, §14.2.4), the badge "Du tog bort den här" (home_sync_lost_deleted) when the refused change was a deletion, and "Visa nuvarande version" (home_sync_lost_show_current), which opens S-8 or S-10; when lost is null (unreadable payload, or its category or a user is not on the phone) "Öppna" opens the current version directly 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); account, payer and split as three fixed full-width rows (Konto, Betalt av, Delning; D-102, D-107) plus day of month (a new template defaults to today's day, AC-9.8, D-072), first month, optional last month, pause switch; a new template is sent as NewRecurringTemplate (RecurringForm.newTemplate()), an edit as RecurringTemplate (RecurringForm.template(original, …)), D-091; the discard dialog has no body text (D-080) Add; Edit; Pause/Resume; Delete S-20 Devices Rows per AC-3.1; the app version shows Device.appVersionName ("Kvitt 1.1"), else the version code (D-090) Revoke (with confirmation) S-21 Invites For the admin: "Invite partner" (only before the partner joins) and "Pair another device" for either user; a created invite shows the code in groups of 4, the link, expiry, and a share action (Android share sheet); list of pending invites with cancel Create; Share; Cancel S-22 About App version name and code, server URL, server version from E-1 /health, fetched when S-22 opens while online, shown as " (schema )" (settings_about_server_version_schema, "1.1.0 (schema 3)"); offline or when the call fails, the X-Kvitt-Server-Version header of the last response without the schema (AboutState, §14.2.4, D-110); no open-source licences row (OQ-5, D-099); after "Check for update": UpdateCheck.UpToDate shows "Du har den senaste versionen" (settings_about_up_to_date), UpdateCheck.Failed shows "Kunde inte nå servern" (common_could_not_reach_server) (AC-16.6, D-092) Check for update

System notifications (copy needed in both languages, built per AC-13.5):

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"

The UX agent writes the Swedish versions.

Invite landing page (E-4, AC-2.5). Server-rendered HTML, so these strings live in the server (M3's src/api/landing.rs), not in the app's resources:

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.

The download button is omitted while no APK is published; the 404 page shows only "Kvitt" and "404" (AC-2.5).

13.4 Deliverables

Phase 1 (U1): directions. In docs/ux/directions/:

  • 2 or 3 visual directions, each a self-contained HTML file docs/ux/directions/<n>-<slug>/index.html (no external requests except Google Fonts, if a direction uses a font whose licence allows bundling in an app; the font must then also be bundleable as an Android font resource) rendering at a 411 dp wide phone frame: S-4 Home, S-7 Expense editor, S-5 Account detail, S-9 Settle up, S-11 Analytics, S-8 Expense detail with timeline, each in light and dark, in Swedish, with realistic household data (names "Jacob" and "Anna", Swedish shops and amounts; no lorem ipsum).
  • Each direction states: name and one-line idea; colour palette (primary, secondary, tertiary, surfaces, error, "owed" and "owing" semantic colours, the 12 category colours) in light and dark with contrast ratios; typography (Material 3 type scale and why; tabular figures for amounts; the app bundles no font and uses the Material 3 default typeface, D-020, so a direction that previews another typeface has it replaced by the default in U2's design spec); shape (corner radii per component size); elevation and motion principles; iconography (Material Symbols style: outlined, rounded or sharp, weight, fill rules); how the balance header looks in its three states; how it handles 200 % font scale (one screenshot).
  • docs/ux/directions/README.md: a comparison table of the directions (character, strengths, risks, implementation cost in Compose), and the UX agent's recommendation with reasons.
  • PNG exports of every screen of every direction (docs/ux/directions/<n>-<slug>/png/), so the orchestrator can show them to the owner without a browser.

Suggested starting points the UX agent may use, replace or merge (it must deliver at least two clearly different ones):

  1. "Kvitto": receipt-paper calm. Off-white surfaces, ink-black text, one accent colour, monospaced tabular amounts, dashed dividers like a till receipt. Expressive only in motion.
  2. "Skärgård": Material 3 Expressive with a cool sea-and-granite palette, large rounded shapes, bold balance header, playful but quiet.
  3. "Kontor": dense, utilitarian, data-forward. Neutral greys, strong typographic hierarchy, charts as the hero on Analytics, minimal decoration.

Phase 2 (U2): design spec for the chosen direction. After the owner picks (and comments), in docs/ux/. The owner picked direction 3 "Kalas" with the muted palette of docs/ux/directions/3-kalas/, typeface per D-020 (D-047). U2 landed 6da6b1b, approved by the owner. docs/ux/design-spec.md is the binding design document for :core:design (§2 tokens, §3 components) and for every screen (§4, §5); this spec wins over it on rules. Amended after U2: chart bars have top corners 4 dp and bottom corners 2 dp (D-097, design-spec §2.9 and §3.10); S-7's account, date, payer and split options are four fixed rows, EditorOptionRows, and connected buttons have 8 dp side padding (D-102, design-spec §3.7 and §3.10); S-22 has no licences row (D-099, design-spec S-22); S-16's lost version (D-109) and S-22's "(schema )" (D-110), design-spec S-16 and S-22; the mock-up PNGs are compressed losslessly (D-098). docs/ux/strings.csv (558 rows at U2, header included; 559 since v0.10 with ledger_settlement_label, D-068, and English "Dining" for category_default_restaurants, D-067; 578 since v0.11 with the shared keys common_ok, common_undo and editor_split_full_on_you and the module keys of M7b to M7f recorded as built, D-079; 577 since v0.16 without settings_about_licences, D-099; 562 since v0.18: M12f added home_sync_lost_title, home_sync_lost_body, home_sync_lost_deleted, home_sync_lost_show_current (D-109) and settings_about_server_version_schema (D-110) and removed the 19 keys (20 rows) that R8 found unused (a0259ab); keys with the D-017 prefixes; plurals as key:one / key:other) is the source of every string resource:

  • design-spec.md: final tokens (colour roles light and dark, category colour hex values, typography scale, shapes, spacing scale in dp, elevation, motion durations and easing), component inventory (each with anatomy, states, sizes), and for every screen S-1 to S-22: layout description, all states of §13.3, interaction details, TalkBack labels.
  • strings.csv: every user-visible string of the app with columns key,en,sv,notes, keys in snake_case prefixed by the feature module that holds them (home_balance_owes_you, expense_save; shared strings for :core:design never start with a feature name; the app name is the only :app string; §6.2, D-017), placeholders as Android format arguments (%1$s), plurals marked in notes with their quantities. This becomes values/strings.xml and values-sv/strings.xml in M6 and M7; feature agents add no strings that are not in this file without a decisions entry. Keys recorded by D-079 (v0.11): shared common_ok, common_undo, editor_split_full_on_you ("All on you" / "Allt på dig"; ledger_settlement_label was D-068); module keys as built: analytics_granularity, analytics_measure, analytics_group_by, analytics_range_apply; settle_row_amount, settle_detail_delete, settle_detail_sync_now; expense_detail_sync_now; home_archived_expanded, home_archived_collapsed; settings_category_archived_snackbar, settings_category_unarchived_snackbar, settings_recurring_summary_none, settings_expanded, settings_collapsed, settings_partner_missing.
  • screens/<S-id>-<slug>.png for every screen and state, light and dark, Swedish.
  • Updated colour hex values for the 14 tokens of §9.7 (the muted Kalas values, slate and amber included), proposed as a spec change (decisions entry, spec version bump) by the orchestrator (D-051).

13.5 Out of scope for the UX sprint

New features, changes to flows required by §4, changes to enums or limits, app icon production (a direction may sketch one), animations beyond descriptions, Kotlin code.


14. Contracts

Contracts are what lets agents work in parallel. Each subsection is binding. The files that M1 writes from this section are listed in §14.8. A change to any contract follows §14.8.

14.1 SQLite schema v1 (server) and migrations

The server schema is at version 3: the v1 DDL below (migration 0001) plus migration 0002 (§14.1.1, D-054) and migration 0003 (§14.1.2, D-090). This DDL is the content of contracts/schema/server-v1.sql and of the server's first migration server/crates/kvitt-server/migrations/0001_init.sql, byte for byte (a test asserts equality). Conventions: ids TEXT (UUIDv7, see the conventions at the top of this file); money INTEGER öre; timestamps TEXT in the fixed RFC 3339 form; booleans INTEGER 0/1; server_version from sync_sequence. Enum and token lists that may grow (icons, colours) are checked in code, not in DDL. Every connection sets the pragmas of §8.9.

-- Kvitt server schema v1. Do not edit; add a new migration and bump the schema version (SPEC §14.8).

CREATE TABLE meta (
    key   TEXT PRIMARY KEY NOT NULL,
    value TEXT NOT NULL
) STRICT;

-- One global, strictly increasing counter for server_version. Exactly one row.
CREATE TABLE sync_sequence (
    id    INTEGER PRIMARY KEY CHECK (id = 1),
    value INTEGER NOT NULL
) STRICT;

CREATE TABLE users (
    id              TEXT PRIMARY KEY NOT NULL,
    role            TEXT NOT NULL CHECK (role IN ('admin', 'member')),
    display_name    TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 40),
    picture_version INTEGER CHECK (picture_version IS NULL OR picture_version >= 1),
    created_at      TEXT NOT NULL,
    updated_at      TEXT NOT NULL,
    server_version  INTEGER NOT NULL UNIQUE
) STRICT;
CREATE UNIQUE INDEX users_single_admin ON users (role) WHERE role = 'admin';

CREATE TABLE devices (
    id                 TEXT PRIMARY KEY NOT NULL,
    user_id            TEXT NOT NULL REFERENCES users (id),
    name               TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
    token_hash         TEXT NOT NULL UNIQUE,
    fcm_token          TEXT,
    app_version_code   INTEGER,
    created_at         TEXT NOT NULL,
    last_seen_at       TEXT NOT NULL,
    revoked_at         TEXT,
    revoked_by_user_id TEXT REFERENCES users (id)
) STRICT;
CREATE INDEX devices_user ON devices (user_id);

CREATE TABLE invites (
    id                 TEXT PRIMARY KEY NOT NULL,
    kind               TEXT NOT NULL CHECK (kind IN ('admin_setup', 'partner', 'device')),
    code_hash          TEXT NOT NULL UNIQUE,
    target_user_id     TEXT REFERENCES users (id),
    created_by_user_id TEXT REFERENCES users (id),
    created_at         TEXT NOT NULL,
    expires_at         TEXT NOT NULL,
    accepted_at        TEXT,
    accepted_device_id TEXT REFERENCES devices (id),
    cancelled_at       TEXT,
    CHECK ((kind = 'device') = (target_user_id IS NOT NULL)),
    CHECK ((kind = 'admin_setup') = (created_by_user_id IS NULL) OR kind = 'device')
) STRICT;

CREATE TABLE accounts (
    id                 TEXT PRIMARY KEY NOT NULL,
    name               TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60),
    name_key           TEXT NOT NULL,
    created_by_user_id TEXT NOT NULL REFERENCES users (id),
    created_at         TEXT NOT NULL,
    updated_at         TEXT NOT NULL,
    archived_at        TEXT,
    deleted_at         TEXT,
    server_version     INTEGER NOT NULL UNIQUE
) STRICT;
-- name_key = Unicode-lowercased, trimmed name, computed in Rust.
CREATE UNIQUE INDEX accounts_name_live ON accounts (name_key) WHERE deleted_at IS NULL;

CREATE TABLE account_default_shares (
    account_id TEXT NOT NULL REFERENCES accounts (id),
    user_id    TEXT NOT NULL REFERENCES users (id),
    share_bp   INTEGER NOT NULL CHECK (share_bp BETWEEN 0 AND 10000),
    PRIMARY KEY (account_id, user_id)
) STRICT, WITHOUT ROWID;

CREATE TABLE categories (
    id             TEXT PRIMARY KEY NOT NULL,
    default_key    TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN
                       ('groceries', 'restaurants', 'home', 'utilities', 'transport',
                        'travel', 'entertainment', 'health', 'shopping', 'other')),
    name           TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40),
    icon           TEXT NOT NULL,
    color          TEXT NOT NULL,
    sort_order     INTEGER NOT NULL,
    created_at     TEXT NOT NULL,
    updated_at     TEXT NOT NULL,
    archived_at    TEXT,
    deleted_at     TEXT,
    server_version INTEGER NOT NULL UNIQUE,
    CHECK (name IS NOT NULL OR default_key IS NOT NULL)
) STRICT;

CREATE TABLE recurring_templates (
    id                    TEXT PRIMARY KEY NOT NULL,
    account_id            TEXT NOT NULL REFERENCES accounts (id),
    category_id           TEXT NOT NULL REFERENCES categories (id),
    amount_ore            INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000),
    description           TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100),
    notes                 TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000),
    payer_user_id         TEXT NOT NULL REFERENCES users (id),
    split_mode            TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')),
    day_of_month          INTEGER NOT NULL CHECK (day_of_month BETWEEN 1 AND 31),
    start_period          TEXT NOT NULL,
    end_period            TEXT,
    paused                INTEGER NOT NULL DEFAULT 0 CHECK (paused IN (0, 1)),
    last_generated_period TEXT,
    created_by_user_id    TEXT NOT NULL REFERENCES users (id),
    updated_by_user_id    TEXT NOT NULL REFERENCES users (id),
    created_at            TEXT NOT NULL,
    updated_at            TEXT NOT NULL,
    deleted_at            TEXT,
    server_version        INTEGER NOT NULL UNIQUE
) STRICT;
CREATE INDEX recurring_templates_account ON recurring_templates (account_id);

-- Modes ratio and full: share_bp set, share_ore NULL. Mode exact: share_ore set, share_bp NULL. Mode default: no rows.
CREATE TABLE recurring_template_splits (
    template_id TEXT NOT NULL REFERENCES recurring_templates (id),
    user_id     TEXT NOT NULL REFERENCES users (id),
    share_bp    INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000),
    share_ore   INTEGER CHECK (share_ore IS NULL OR share_ore >= 0),
    PRIMARY KEY (template_id, user_id),
    CHECK ((share_bp IS NULL) <> (share_ore IS NULL))
) STRICT, WITHOUT ROWID;

CREATE TABLE expenses (
    id                    TEXT PRIMARY KEY NOT NULL,
    account_id            TEXT NOT NULL REFERENCES accounts (id),
    category_id           TEXT NOT NULL REFERENCES categories (id),
    amount_ore            INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000),
    description           TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100),
    notes                 TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000),
    expense_date          TEXT NOT NULL,
    payer_user_id         TEXT NOT NULL REFERENCES users (id),
    split_mode            TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')),
    recurring_template_id TEXT REFERENCES recurring_templates (id),
    recurring_period      TEXT,
    import_key            TEXT UNIQUE,
    created_by_user_id    TEXT NOT NULL REFERENCES users (id),
    updated_by_user_id    TEXT NOT NULL REFERENCES users (id),
    updated_by_device_id  TEXT REFERENCES devices (id),
    created_at            TEXT NOT NULL,
    updated_at            TEXT NOT NULL,
    deleted_at            TEXT,
    server_received_at    TEXT NOT NULL,
    server_version        INTEGER NOT NULL UNIQUE,
    CHECK ((recurring_template_id IS NULL) = (recurring_period IS NULL))
) STRICT;
CREATE UNIQUE INDEX expenses_recurring_once ON expenses (recurring_template_id, recurring_period)
    WHERE recurring_template_id IS NOT NULL;
CREATE INDEX expenses_account ON expenses (account_id);
CREATE INDEX expenses_category ON expenses (category_id);

-- Exactly two rows per expense, one per household user. share_bp NULL for mode exact.
CREATE TABLE expense_splits (
    expense_id TEXT NOT NULL REFERENCES expenses (id),
    user_id    TEXT NOT NULL REFERENCES users (id),
    share_ore  INTEGER NOT NULL CHECK (share_ore >= 0),
    share_bp   INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000),
    PRIMARY KEY (expense_id, user_id)
) STRICT, WITHOUT ROWID;

CREATE TABLE settlements (
    id                   TEXT PRIMARY KEY NOT NULL,
    from_user_id         TEXT NOT NULL REFERENCES users (id),
    to_user_id           TEXT NOT NULL REFERENCES users (id),
    amount_ore           INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000),
    settlement_date      TEXT NOT NULL,
    note                 TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 200),
    import_key           TEXT UNIQUE,
    created_by_user_id   TEXT NOT NULL REFERENCES users (id),
    updated_by_user_id   TEXT NOT NULL REFERENCES users (id),
    updated_by_device_id TEXT REFERENCES devices (id),
    created_at           TEXT NOT NULL,
    updated_at           TEXT NOT NULL,
    deleted_at           TEXT,
    server_received_at   TEXT NOT NULL,
    server_version       INTEGER NOT NULL UNIQUE,
    CHECK (from_user_id <> to_user_id)
) STRICT;

CREATE TABLE settlement_allocations (
    settlement_id TEXT NOT NULL REFERENCES settlements (id),
    account_id    TEXT NOT NULL REFERENCES accounts (id),
    amount_ore    INTEGER NOT NULL CHECK (amount_ore >= 1),
    PRIMARY KEY (settlement_id, account_id)
) STRICT, WITHOUT ROWID;
CREATE INDEX settlement_allocations_account ON settlement_allocations (account_id);

-- Append-only. changes is a JSON array of {"field","old","new"} (SPEC §14.4.7).
CREATE TABLE audit_log (
    id              TEXT PRIMARY KEY NOT NULL,
    entity_type     TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')),
    entity_id       TEXT NOT NULL,
    action          TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')),
    source          TEXT NOT NULL CHECK (source IN ('app', 'recurring', 'import')),
    actor_user_id   TEXT NOT NULL REFERENCES users (id),
    actor_device_id TEXT REFERENCES devices (id),
    client_at       TEXT NOT NULL,
    server_at       TEXT NOT NULL,
    changes         TEXT NOT NULL,
    server_version  INTEGER NOT NULL UNIQUE
) STRICT;
CREATE INDEX audit_log_entity ON audit_log (entity_type, entity_id);

-- Idempotency of POST /sync. Rows older than 90 days are pruned daily.
CREATE TABLE sync_mutations (
    mutation_id TEXT PRIMARY KEY NOT NULL,
    device_id   TEXT NOT NULL REFERENCES devices (id),
    entity_type TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')),
    entity_id   TEXT NOT NULL,
    result      TEXT NOT NULL,
    received_at TEXT NOT NULL
) STRICT;
CREATE INDEX sync_mutations_received ON sync_mutations (received_at);

INSERT INTO sync_sequence (id, value) VALUES (1, 10);
INSERT INTO meta (key, value) VALUES ('schema_version', '1');
INSERT INTO meta (key, value) VALUES ('sync_epoch', lower(hex(randomblob(16))));

INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) VALUES
    ('01920000-0000-7000-8000-000000000001', 'groceries',     NULL, 'shopping_cart',    'green',  10,  '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 1),
    ('01920000-0000-7000-8000-000000000002', 'restaurants',   NULL, 'restaurant',       'orange', 20,  '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 2),
    ('01920000-0000-7000-8000-000000000003', 'home',          NULL, 'home',             'brown',  30,  '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 3),
    ('01920000-0000-7000-8000-000000000004', 'utilities',     NULL, 'bolt',             'yellow', 40,  '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 4),
    ('01920000-0000-7000-8000-000000000005', 'transport',     NULL, 'directions_car',   'blue',   50,  '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 5),
    ('01920000-0000-7000-8000-000000000006', 'travel',        NULL, 'flight',           'teal',   60,  '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 6),
    ('01920000-0000-7000-8000-000000000007', 'entertainment', NULL, 'movie',            'purple', 70,  '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 7),
    ('01920000-0000-7000-8000-000000000008', 'health',        NULL, 'medical_services', 'red',    80,  '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 8),
    ('01920000-0000-7000-8000-000000000009', 'shopping',      NULL, 'shopping_bag',     'pink',   90,  '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 9),
    ('01920000-0000-7000-8000-00000000000a', 'other',         NULL, 'category',         'grey',   100, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 10);

Server-side rules on top of the DDL:

  • Every write transaction starts with BEGIN IMMEDIATE. Taking a server_version is UPDATE sync_sequence SET value = value + 1 WHERE id = 1 RETURNING value inside that transaction.
  • At most two rows in users (checked in code; household_full).
  • meta.sync_epoch is replaced by kvitt-server rotate-epoch with a new lower(hex(randomblob(16))).
  • No row of users, devices, accounts, categories, recurring_templates, expenses, settlements or audit_log is ever hard-deleted. Child rows (account_default_shares, recurring_template_splits, expense_splits, settlement_allocations) are replaced (delete and insert) when their parent is updated.
  • invites rows are never deleted; sync_mutations rows are pruned after 90 days by a daily job at 04:00 Europe/Stockholm (DST-aware, always local 04:00) that deletes rows whose received_at is strictly older than 90 days; a row exactly 90 days old is kept (D-021, D-039).

14.1.1 Migration 0002: schema version 2 (D-054)

Migration 0002 adds the four default categories housing, insurance, children and fees of AC-7.2 to every database, existing ones (the development stack included) on upgrade and new ones right after 0001. The SQL below is the content of the contract file contracts/schema/migrations/0002_categories_v2.sql as M12a committed it (aa765bb; the file wins over this copy) and of the server migration server/crates/kvitt-server/migrations/0002_categories_v2.sql, byte for byte (a test asserts equality, as for 0001); contracts/schema/server-v1.sql stays unchanged. Every later migration follows the same pattern: contracts/schema/migrations/<NNNN>_<name>.sql is its locked text.

  • The v1 CHECK on categories.default_key lists ten keys and SQLite cannot alter a CHECK, so the migration rebuilds categories with the fourteen keys (the ten of v1 followed by the four new ones) and otherwise the same columns and constraints, keeping every row unchanged (custom categories, renames, archive state, server_version).
  • sqlx runs the migration in one transaction on a connection with foreign_keys = ON (§8.9), where PRAGMA foreign_keys cannot change. PRAGMA defer_foreign_keys = ON defers the checks to the commit; the rows are copied out, the table dropped and recreated under its own name, and the rows copied back, so every reference from expenses and recurring_templates resolves again before the commit.
  • Each new row takes its server_version from the global sequence (§14.1 rules), so phones receive the four rows in their next pull like any other change. Ids 01920000-0000-7000-8000-000000000011 to …014, created_at = updated_at = 2026-10-18T00:00:00.000Z, name null.
  • meta.schema_version becomes 2; the server's SCHEMA_VERSION constant (E-1) becomes 2, and the importer's check (§10.8), which reads that constant, follows.
  • The Room schema is unchanged (version 1): category rows reach the phone through sync (§14.2).
-- Kvitt server schema v2: four new default categories (housing, insurance, children, fees). Do not edit; add a new
-- migration and bump the schema version (SPEC §14.8). Applied on top of server-v1.sql (0001_init.sql).
--
-- The default_key CHECK of categories cannot be altered in place in SQLite, so the table is rebuilt: copy the rows
-- aside, drop the table, recreate it with the new CHECK and copy the rows back, ids and server_version unchanged.
-- The migration runs inside one transaction (sqlx wraps every migration in one) with foreign_keys = ON (SPEC §8.9).
-- defer_foreign_keys = ON lets expenses and recurring_templates point at categories while the table is briefly
-- absent; the deferred check at commit still fails the migration if any reference is left dangling. It resets
-- itself when the transaction ends. No other table, index or trigger refers to categories by anything but name.
PRAGMA defer_foreign_keys = ON;

CREATE TABLE categories_v1_copy AS SELECT * FROM categories;
DROP TABLE categories;

CREATE TABLE categories (
    id             TEXT PRIMARY KEY NOT NULL,
    default_key    TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN
                       ('groceries', 'restaurants', 'home', 'utilities', 'transport',
                        'travel', 'entertainment', 'health', 'shopping', 'other',
                        'housing', 'insurance', 'children', 'fees')),
    name           TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40),
    icon           TEXT NOT NULL,
    color          TEXT NOT NULL,
    sort_order     INTEGER NOT NULL,
    created_at     TEXT NOT NULL,
    updated_at     TEXT NOT NULL,
    archived_at    TEXT,
    deleted_at     TEXT,
    server_version INTEGER NOT NULL UNIQUE,
    CHECK (name IS NOT NULL OR default_key IS NOT NULL)
) STRICT;

INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
    SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version
    FROM categories_v1_copy;
DROP TABLE categories_v1_copy;

-- Each new row takes the next server_version from the global sequence, as every server write does (SPEC §14.1), so
-- phones receive the rows on their next pull whatever their cursor.
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
    SELECT '01920000-0000-7000-8000-000000000011', 'housing', NULL, 'key', 'indigo', 25,
           '2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
    FROM sync_sequence WHERE id = 1;

UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
    SELECT '01920000-0000-7000-8000-000000000012', 'insurance', NULL, 'shield', 'slate', 45,
           '2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
    FROM sync_sequence WHERE id = 1;

UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
    SELECT '01920000-0000-7000-8000-000000000013', 'children', NULL, 'child_care', 'lime', 85,
           '2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
    FROM sync_sequence WHERE id = 1;

UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
    SELECT '01920000-0000-7000-8000-000000000014', 'fees', NULL, 'account_balance', 'amber', 95,
           '2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
    FROM sync_sequence WHERE id = 1;

UPDATE meta SET value = '2' WHERE key = 'schema_version';

14.1.2 Migration 0003: schema version 3 (D-090)

Migration 0003 adds the column devices.app_version_name (the app's versionName, from the request header X-Kvitt-App-Version-Name, §14.3.1), written like app_version_code (§8.2). The SQL below is the content of the contract file contracts/schema/migrations/0003_device_version_name.sql as M12e committed it (8c60715; the file wins over this copy) and of the server migration server/crates/kvitt-server/migrations/0003_device_version_name.sql, byte for byte (a test asserts equality, as for 0001 and 0002).

  • Existing devices keep every column and get NULL until their next request that carries the header (at most once per hour, §8.2); E-3 stores it at pairing.
  • meta.schema_version becomes 3; the server's SCHEMA_VERSION constant (E-1) becomes 3, and the importer's check (§10.8) follows. A migration test upgrades a v2 database in use to schema 3 and asserts that every row is kept.
  • The Room schema is unchanged (version 1): the phone stores no devices (§14.2).
-- Kvitt server schema v3: the app's versionName per device (devices.app_version_name), sent in the
-- X-Kvitt-App-Version-Name header and written like app_version_code (SPEC §14.3.1, §8.2). Do not edit; add a new
-- migration and bump the schema version (SPEC §14.8). Applied on top of 0002_categories_v2.sql.
--
-- Existing devices keep every column and get NULL until their next request that carries the header.
ALTER TABLE devices ADD COLUMN app_version_name TEXT
    CHECK (app_version_name IS NULL OR length(app_version_name) BETWEEN 1 AND 32);

UPDATE meta SET value = '3' WHERE key = 'schema_version';

14.2 Room entities (phone)

Database class KvittDatabase in :data, file name kvitt.db, version 1, exportSchema = true with schemas in android/data/schemas/ (committed). Package se.hagstedt.kvitt.data.db. Room 3 (androidx.room3, KSP only) on an SQLiteDriver with sqlite-bundled (D-012). M2b created the entities and KvittDatabase version 1; the exported android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json is a contract file (§14.8).

Rules:

  • Table and column names are identical to the server's (§14.1) where the column exists on both. Kotlin properties are camelCase with @ColumnInfo(name = "snake_case").
  • No foreign keys in Room: sync pages may deliver a child before its parent (a parent updated later carries a higher server_version). Queries that feed the UI join and skip rows whose references do not resolve yet.
  • Server-only columns are not mirrored: token_hash, fcm_token, name_key, import_key, server_received_at, updated_by_device_id, code_hash. Tables devices, invites, meta, sync_sequence, sync_mutations are not mirrored.
  • Timestamps are String in the fixed RFC 3339 form; dates String YYYY-MM-DD; money Long; bp Int; booleans Boolean.
  • local_updated_at (Long, epoch milliseconds, phone clock) is set on every local write and on every row written from the network. It orders the push queue and is never sent.

14.2.1 Mirrored tables

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)

Local-only semantics on expenses and settlements:

  • pending = true means the row has a local change not yet accepted by the server. Every local create, edit or delete sets pending = true, sets a new UUIDv7 pending_mutation_id, sets updated_at to the phone's current time in the fixed RFC 3339 form (and created_at on create), and sets local_updated_at.
  • A local delete sets deleted_at = updated_at = now. The row stays as a tombstone.
  • The pending-changes count (AC-15.4) is count(expenses where pending) + count(settlements where pending).

14.2.2 Local-only tables

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

Per-phone preferences (last used account, last used category per account, "install unknown apps" explanation shown, and the installed version code that received a 426 protocol_unsupported, D-043) live in DataStore prefs.preferences_pb, not Room. The pairing status lives in auth.preferences_pb with the token (§8.2, D-043).

14.2.3 Migrations

Version 1 has no migrations. Every later Room version adds a Migration and a migration test in :data (CLAUDE.md), and is a contract change (§14.8). Room 3 migrations operate on an SQLiteConnection (the driver API), not on a SupportSQLiteDatabase (D-012).

14.2.4 UI contract: types and repository interfaces in :core:model

Package se.hagstedt.kvitt.model. Features see only these. :data implements them; :core:fakes fakes them. Flows are cold kotlinx.coroutines.flow.Flow; suspend functions are main-safe.

// Ids and enums
@JvmInline value class UserId(val value: String)
@JvmInline value class AccountId(val value: String)
@JvmInline value class CategoryId(val value: String)
@JvmInline value class ExpenseId(val value: String)
@JvmInline value class SettlementId(val value: String)
@JvmInline value class TemplateId(val value: String)
@JvmInline value class DeviceId(val value: String)
@JvmInline value class InviteId(val value: String)

enum class Role { ADMIN, MEMBER }
enum class SplitMode { DEFAULT, RATIO, EXACT, FULL }
enum class InviteKind { ADMIN_SETUP, PARTNER, DEVICE }
enum class EntityType { EXPENSE, SETTLEMENT }
enum class AuditAction { CREATE, UPDATE, DELETE }
enum class AuditSource { APP, RECURRING, IMPORT }
enum class DefaultCategoryKey { GROCERIES, RESTAURANTS, HOUSING, HOME, UTILITIES, INSURANCE, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, CHILDREN, SHOPPING, FEES, OTHER }   // AC-7.2 order (D-054)
enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME, SLATE, AMBER }
enum class RangePreset { THIS_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_12_MONTHS, THIS_YEAR, ALL_TIME, CUSTOM }
enum class Granularity { MONTH, WEEK }
enum class Measure { HOUSEHOLD, MY_SHARE }
enum class GroupBy { CATEGORY, ACCOUNT }
enum class SyncRejectionReason { STALE, DELETED, VALIDATION_FAILED, REFERENCE_NOT_FOUND, CLOCK_SKEW }
// Wire value of every enum = lowercase name (SplitMode.DEFAULT <-> "default"). Icon tokens are Strings from CategoryIcons.ALL (§9.7).
object CategoryIcons { val ALL: List<String> /* the 33 tokens of §9.7, in that order */ }

// Plain records
data class User(val id: UserId, val role: Role, val displayName: String, val pictureVersion: Int?)
data class Account(val id: AccountId, val name: String, val defaultShares: Map<UserId, Int>, val createdAt: String, val archived: Boolean)
data class Category(val id: CategoryId, val defaultKey: DefaultCategoryKey?, val customName: String?, val icon: String, val color: ColorToken, val sortOrder: Int, val archived: Boolean)
data class Share(val userId: UserId, val shareOre: Long, val shareBp: Int?)

// Results
sealed interface OnlineResult<out T> {
    data class Ok<T>(val value: T) : OnlineResult<T>
    data object Offline : OnlineResult<Nothing>
    data class Failed(val code: String, val field: String?) : OnlineResult<Nothing>   // code from §14.3.2, or "network" when the server could not be reached (D-029)
}
sealed interface SaveResult<out T> {
    data class Saved<T>(val id: T) : SaveResult<T>
    data class Invalid(val errors: List<FieldError>) : SaveResult<Nothing>
}
data class FieldError(val field: String, val error: String)   // error = a domain error token of §14.5.3, "required", or "validation_failed" for a §9.6 rule without a domain token

// Session and onboarding (S-1, S-2, S-3, S-18)
data class Pairing(val user: User, val inviteKind: InviteKind)   // v0.13, D-086: the E-3 invite_kind; an unknown wire value reads as DEVICE
sealed interface SessionState {
    data object Unpaired : SessionState
    data class Paired(val me: User, val partner: User?, val deviceId: DeviceId) : SessionState
    data object Revoked : SessionState
    data object Unrecognised : SessionState   // 401 unauthorized: pair again, data kept (§14.4.8)
}
interface SessionRepository {
    val session: Flow<SessionState>
    suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<Pairing>   // v0.13, D-086
    suspend fun acknowledgeRevoked()   // wipes local data (AC-3.3) and moves to Unpaired
}

// Balances shared by several screens. netOre is the viewer's net (§9.3): > 0 partner owes viewer.
data class BalanceView(val netOre: Long, val partner: User)

// Home (S-4)
data class AccountSummary(val account: Account, val netOre: Long, val expensesThisMonth: Int)
data class SyncSummary(val lastSuccessAt: String?, val pendingCount: Int, val rejectedCount: Int, val clockSkew: Boolean, val running: Boolean, val lastAttemptFailed: Boolean)   // lastAttemptFailed: v0.13, D-088
data class HomeState(val me: User, val partner: User?, val global: BalanceView?, val active: List<AccountSummary>, val archived: List<AccountSummary>, val sync: SyncSummary, val update: UpdateInfo?)
interface HomeRepository { fun home(): Flow<HomeState> }

// Ledger rows (S-5, S-12)
sealed interface ExpenseEffect {
    data class Lent(val ore: Long) : ExpenseEffect
    data class Borrowed(val ore: Long) : ExpenseEffect
    data object NotInvolved : ExpenseEffect
    data object PaidForYourself : ExpenseEffect
}
sealed interface LedgerRow {
    data class ExpenseRow(val id: ExpenseId, val date: String, val description: String, val category: Category, val amountOre: Long, val effect: ExpenseEffect, val pending: Boolean) : LedgerRow
    data class SettlementRow(val id: SettlementId, val date: String, val from: User, val to: User, val amountOre: Long, val accountCount: Int, val pending: Boolean) : LedgerRow   // amountOre: in an account's list (S-5) the allocation to that account, elsewhere the total (AC-11.7); accountCount: allocations of the whole settlement
}
data class LedgerMonth(val period: String, val householdSpendOre: Long, val rows: List<LedgerRow>)

// Accounts (S-5, S-6)
data class AccountDetailState(val account: Account, val balance: BalanceView, val months: List<LedgerMonth>, val deletable: Boolean, val users: List<User>)
interface AccountRepository {
    fun detail(id: AccountId): Flow<AccountDetailState?>
    fun all(): Flow<List<Account>>
    suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId>
    suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit>
    suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit>
    suspend fun delete(id: AccountId): OnlineResult<Unit>
}

// Expenses (S-7, S-8)
data class ExpenseDraft(val id: ExpenseId?, val accountId: AccountId?, val categoryId: CategoryId?, val amountOre: Long?, val description: String, val notes: String, val date: String, val payer: UserId, val mode: SplitMode, val ratioBp: Map<UserId, Int>?, val exactOre: Map<UserId, Long>?, val fullOn: UserId?)
data class ExpenseEditorOptions(val me: User, val partner: User, val accounts: List<Account>, val categories: List<Category>, val defaultAccountId: AccountId?, val defaultCategoryFor: Map<AccountId, CategoryId>, val today: String, val frequentCategoryIds: Map<AccountId, List<CategoryId>>)   // frequentCategoryIds: v0.9, D-059
data class TimelineLine(val actor: User?, val action: AuditAction, val source: AuditSource, val clientAt: String, val changes: List<FieldChange>, val pendingLocal: Boolean)
data class FieldChange(val field: String, val oldDisplay: String?, val newDisplay: String?)   // ids already resolved to names; amounts and dates raw (öre as String, ISO date), formatted by the UI
data class ExpenseDetailState(val id: ExpenseId, val account: Account, val category: Category, val amountOre: Long, val description: String, val notes: String, val date: String, val payer: User, val mode: SplitMode, val shares: List<Share>, val recurringTemplateName: String?, val deleted: Boolean, val pending: Boolean, val timeline: List<TimelineLine>)
interface ExpenseRepository {
    fun editorOptions(): Flow<ExpenseEditorOptions>
    suspend fun load(id: ExpenseId): ExpenseDraft?
    fun preview(draft: ExpenseDraft): List<Share>?     // null while the draft cannot be computed
    suspend fun save(draft: ExpenseDraft): SaveResult<ExpenseId>
    suspend fun delete(id: ExpenseId)
    fun detail(id: ExpenseId): Flow<ExpenseDetailState?>
    suspend fun suggestCategory(accountId: AccountId?, description: String): CategoryId?   // §9.11, D-049; null only while the phone holds no categories
}

// Settlements (S-9, S-10)
data class AllocationLine(val account: Account, val payerNetOre: Long, val amountOre: Long, val overSettles: Boolean)
data class UntouchedAccount(val account: Account, val payerNetOre: Long)   // v0.13, D-093
data class SettleState(val payer: User, val recipient: User, val amountOre: Long?, val lines: List<AllocationLine>, val remainingOre: Long, val canConfirm: Boolean, val date: String, val note: String, val untouched: List<UntouchedAccount>)   // untouched: v0.13, D-093
data class SettleRequest(val editing: SettlementId?, val payer: UserId, val amountOre: Long?, val fixedAccount: AccountId?, val allocations: Map<AccountId, Long>?, val date: String, val note: String)
data class SettlementDetailState(val id: SettlementId, val from: User, val to: User, val amountOre: Long, val date: String, val note: String, val allocations: List<Pair<Account, Long>>, val deleted: Boolean, val pending: Boolean, val timeline: List<TimelineLine>)
interface SettlementRepository {
    suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState?   // defaults per AC-11.3 / AC-11.4; null without a paired session or partner (v0.18, D-111)
    suspend fun recompute(request: SettleRequest): SettleState?  // allocations == null: propose (§9.4); else validate and warn; null as start (D-111)
    // save without a session or partner: SaveResult.Invalid([FieldError("payer", "validation_failed")]); no method throws (D-111)
    suspend fun save(request: SettleRequest): SaveResult<SettlementId>
    suspend fun delete(id: SettlementId)
    fun detail(id: SettlementId): Flow<SettlementDetailState?>
}

// Analytics (S-11, S-12)
data class AnalyticsFilter(val preset: RangePreset, val customStart: String?, val customEnd: String?, val granularity: Granularity, val measure: Measure, val groupBy: GroupBy)
data class GroupKey(val id: String, val label: String?, val color: ColorToken?)   // label null: UI uses the default category name
data class SpendBucket(val key: String, val start: String, val end: String, val totals: Map<String, Long>, val totalOre: Long)
data class AnalyticsState(val filter: AnalyticsFilter, val start: String, val end: String, val groups: List<GroupKey>, val buckets: List<SpendBucket>, val byCategory: List<Pair<GroupKey, Long>>, val byAccount: List<Pair<GroupKey, Long>>, val totalOre: Long)   // no error field: a CUSTOM filter with a missing date is resolved with today for that date; the S-11 range editor never sends one
data class DrillDownState(val title: String, val totalOre: Long, val rows: List<LedgerRow.ExpenseRow>)   // title: unlocalised parts joined by ", " (category custom name or default key, account name, bucket key); S-12 formats its visible title itself from the drillDown arguments
interface AnalyticsRepository {
    fun analytics(filter: AnalyticsFilter): Flow<AnalyticsState>
    fun drillDown(filter: AnalyticsFilter, bucketKey: String?, categoryId: CategoryId?, accountId: AccountId?): Flow<DrillDownState>
}

// Settings (S-13 to S-15, S-19 to S-22)
interface ProfileRepository {
    fun household(): Flow<List<User>>
    suspend fun setDisplayName(name: String): OnlineResult<Unit>
    suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult<Unit>
    suspend fun removePicture(): OnlineResult<Unit>
}
interface CategoryRepository {
    fun all(): Flow<List<Category>>
    suspend fun create(name: String, icon: String, color: ColorToken): OnlineResult<CategoryId>
    suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken): OnlineResult<Unit>   // name null = reset default name
    suspend fun reorder(idsInOrder: List<CategoryId>): OnlineResult<Unit>
    suspend fun setArchived(id: CategoryId, archived: Boolean): OnlineResult<Unit>
    suspend fun delete(id: CategoryId): OnlineResult<Unit>
    fun deletable(id: CategoryId): Flow<Boolean>   // v0.9, D-059: false for a default category or one used by a non-deleted expense or non-deleted recurring template (AC-7.6)
}
data class RecurringTemplate(val id: TemplateId, val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean, val nextOccurrence: String?)
data class NewRecurringTemplate(val draft: ExpenseDraft, val dayOfMonth: Int, val startPeriod: String, val endPeriod: String?, val paused: Boolean)   // v0.13, D-091
interface RecurringRepository {
    fun all(): Flow<List<RecurringTemplate>>
    suspend fun create(t: NewRecurringTemplate): OnlineResult<TemplateId>   // v0.13, D-091: the server mints the id
    suspend fun update(t: RecurringTemplate): OnlineResult<Unit>
    suspend fun delete(id: TemplateId): OnlineResult<Unit>
}
data class Device(val id: DeviceId, val user: User, val name: String, val pairedAt: String, val lastSeenAt: String, val appVersionCode: Int?, val appVersionName: String?, val current: Boolean, val revoked: Boolean)   // appVersionName: v0.13, D-090
interface DeviceRepository {
    suspend fun list(): OnlineResult<List<Device>>
    suspend fun revoke(id: DeviceId): OnlineResult<Unit>
}
data class Invite(val id: InviteId, val kind: InviteKind, val targetUser: UserId?, val code: String?, val link: String?, val createdAt: String, val expiresAt: String)   // code and link only right after creation
interface InviteRepository {
    suspend fun list(): OnlineResult<List<Invite>>
    suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite>
    suspend fun cancel(id: InviteId): OnlineResult<Unit>
}
data class Rejection(val mutationId: String, val entityType: EntityType, val entityId: String, val reason: SyncRejectionReason, val summary: String, val rejectedAt: String, val actor: User?, val lost: RejectedVersion?)   // actor: v0.13, D-089; lost: v0.18, D-109
// D-109: the refused version as pushed (sync_rejections.rejected_payload), shown read-only on S-16; lost is null when the payload
// cannot be decoded or its category or a user is missing on the phone. row is the ledger row it would have been (pending false);
// for a settlement accountName, payer and mode are null and shares empty; notes is the expense's notes or the settlement's note.
data class RejectedVersion(val row: LedgerRow, val accountName: String?, val payer: User?, val mode: SplitMode?, val shares: List<Share>, val notes: String, val deleted: Boolean)
data class SyncStatusState(val summary: SyncSummary, val pending: List<LedgerRow>, val rejections: List<Rejection>)
interface SyncRepository {
    fun status(): Flow<SyncStatusState>
    suspend fun syncNow()
    suspend fun dismiss(mutationId: String)
}
data class UpdateInfo(val versionCode: Int, val versionName: String, val sizeBytes: Long)
sealed interface UpdateState {
    data object None : UpdateState
    data class Available(val info: UpdateInfo) : UpdateState
    data class Downloading(val info: UpdateInfo, val progress: Float) : UpdateState
    data class Failed(val info: UpdateInfo) : UpdateState
    data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState
}
sealed interface UpdateCheck {   // v0.13, D-092
    data object UpToDate : UpdateCheck
    data class Available(val info: UpdateInfo) : UpdateCheck
    data object Failed : UpdateCheck
}
interface UpdateRepository {
    val state: Flow<UpdateState>
    suspend fun check(): UpdateCheck   // v0.13, D-092
    suspend fun install()
}
data class AboutState(val versionName: String, val versionCode: Int, val serverUrl: String, val serverVersion: String?, val schemaVersion: Int?)   // schemaVersion: v0.18, D-110
// D-110: serverVersion and schemaVersion from E-1 /health when S-22 opens online; offline or on failure serverVersion is the
// X-Kvitt-Server-Version header of the last response and schemaVersion is null.
interface AboutRepository { fun about(): Flow<AboutState> }
interface ConnectivityRepository { val online: Flow<Boolean> }   // §9.9

// Push (§14.6), v0.13, D-095: model/Push.kt; :adapter:fcm maps FCM data messages into it
data class PushEvent(val type: String, val entityType: String, val entityId: String, val actorUserId: String, val count: Int)

Agents may add fields to these types only through a contract change (§14.8). A feature that needs a value not listed asks the orchestrator.

Readings fixed in v0.4 (no type changes):

  • FieldError.error. Allowed values: a domain token of §14.5.3, required (a required value is missing or blank), or validation_failed (a §9.6 rule with no domain token: lengths, dates, day_of_month, periods).
  • Analytics range errors. AnalyticsState has no error field. AnalyticsRepository.analytics never fails for a CUSTOM filter with customStart or customEnd null: it resolves the missing date as today. The S-11 custom-range editor does not let the user apply a range with a missing date, so this path is defensive only. resolveRange still throws IncompleteCustomRangeException (§14.5.4); the repository supplies today before calling it.
  • DrillDownState.title. Stays a String. The repository fills it with the unlocalised parts as above, as the fakes do. S-12 does not display it as is: the feature formats the visible title ("Mat & hushåll, oktober 2026") from the arguments it passed to drillDown (the category or account, resolved through GroupKey with the default category name from resources, and the bucket key formatted with DateFormatter).
  • Settlement rows in an account's list carry the allocation to that account (AC-11.7).
  • Share names. se.hagstedt.kvitt.model.Share (a split share) and se.hagstedt.kvitt.model.analytics.AnalyticsMeasure.Share (the measure) share a simple name in different packages. Accepted; code that needs both imports one with an alias or qualifies it.

Changes in v0.8 (D-049, D-054):

  • Category suggestion types live in :domain, not here (§14.5.6); :core:model gains only ExpenseRepository.suggestCategory.
  • Fourteen defaults. DefaultCategoryKey gains HOUSING, INSURANCE, CHILDREN, FEES (wire values housing, insurance, children, fees), ColorToken gains SLATE and AMBER, CategoryIcons.ALL gains key, shield, account_balance at the end. The localised default names are those of AC-7.2.
  • ExpenseRepository.suggestCategory. Computed in :data from Room (every non-deleted expense on the phone, pending ones included, across all accounts and both users; and the active categories, neither archived nor deleted), the per-phone last-used category of the account (DataStore, §14.2.2) and the keyword table copied from contracts/category-keywords.csv at build time (D-055) and parsed with parseCategoryKeywords (D-056), by calling :domain's suggestCategory (§14.5.6). :data binds :domain's UnicodeText with java.text.Normalizer and Character, since :domain may not use java.* (§14.5.6). accountId null (no active account) gives the fallback other. The result is null while the phone holds no categories (before the first sync), and when other is archived and no active last-used category exists; the editor then shows no category until the user picks one (AC-8.6, v0.9). Stored expense_date values are valid ISO dates (§9.6), so parsing them for the history cannot fail. The editor (S-7) calls it on every change of the description and of the account while the category was not picked by hand (§9.11 CS-2, CS-3) and never when editing an existing expense (CS-1). ExpenseEditorOptions.defaultCategoryFor stays: it is the step-4 default before any description is typed.

Changes in v0.9 (D-055 to D-057, D-059):

  • Keyword table on the phone (D-055). :data and :core:fakes each copy contracts/category-keywords.csv at build time (tasks :data:copyCategoryKeywords and :core:fakes:copyCategoryKeywords, registered as an AGP generated resource directory); a missing file fails the build. It is packaged as the Java resource se/hagstedt/kvitt/data/category-keywords.csv (and se/hagstedt/kvitt/fakes/category-keywords.csv for the fakes, debug only). There is no committed copy. A :data test asserts byte identity with the contract file and the §14.8 rules.
  • Enum declaration order (D-057). DefaultCategoryKey is declared in AC-7.2 sort order. The Rust wire enum, the OpenAPI DefaultCategoryKey enum and the 0002 CHECK list the ten v1 keys, then housing, insurance, children, fees. Wire values are unaffected (lowercase names; the OpenAPI equality test compares enums as sets).
  • ExpenseEditorOptions.frequentCategoryIds (D-059). Per active account: the active (non-archived, non-deleted) categories ordered by the number of non-deleted expenses in that account that use them, descending; ties by sort_order ascending; at most 5 entries; categories with no use in the account are left out (the list may be shorter or empty). Counted over every expense in the phone's Room copy (both users, imported and recurring included), so both phones show the same order after sync; it is not a per-phone history like AC-8.6's "last used on this phone". The flow re-emits when expenses or categories change; the editor reads it once when it opens and on an account change, so tiles do not move while the user looks at them. UI rule (design, not contract): the row shows the account's list, filled up to five from the remaining active categories in sort_order; the selected category takes the fifth place when it is not among them. Fallback until M12d lands: the selected category first, then the first active categories in sort_order up to five.
  • CategoryRepository.deletable(id) (D-059). Computed from Room: false for a default category (defaultKey != null) or one used by a non-deleted expense or non-deleted recurring template (AC-7.6). The server stays the authority: delete can still return Failed("category_is_default") or Failed("category_in_use") (for example when the partner's new expense has not synced yet), and the UI shows the same reason inline. The editor picks the text from deletable and defaultKey: default → settings_category_delete_blocked_default_short; in use and archived → settings_category_delete_blocked_in_use_archived; in use and active → settings_category_delete_blocked_in_use. Fallback until M12d lands: for a custom category "Ta bort kategori" is enabled and Failed("category_in_use") shows the in-use reason inline after the tap. Both members are implemented in :data and :core:fakes (every fake scenario computes them from its own data) by M12d.

Readings fixed in v0.10 (D-060 to D-062, as landed by M12d 557af7e; no type changes):

  • frequentCategoryIds holds only used categories (D-060). Each list holds only categories actually used in that account, so it may have fewer than five entries or be empty. Filling up to five from sort_order and placing the selected category fifth is the editor's job (UX-9, docs/ux/design-spec.md §3.7), not the repository's.
  • frequentCategoryIds keys and ties (D-061). The map has a key for every active account (an empty list when nothing was used there) and no key for an archived account. Ties: use count descending, then sort_order ascending, then category id ascending, so Room and the fakes give the identical order.
  • deletable edge cases (D-062). CategoryRepository.deletable(id) emits false for an unknown or already deleted id. Archived categories follow the same AC-7.6 rule as active ones. In :core:fakes, delete and deletable share one rule function, so a fake never offers a delete it then refuses.

Readings fixed in v0.11 (D-069, D-073 to D-075, as landed by M7a to M7f and M6b; no type changes):

  • AnalyticsState.groups order (D-069). Unspecified; the UI does not rely on it. S-11 sorts legends and stacks itself: categories by sortOrder, accounts in AccountRepository.all() order.
  • FieldError.field values (D-074). The model property names: amountOre, description, notes, date, split, accountId, categoryId, allocations, as :core:fakes and :data emit them.
  • UpdateRepository.check() (D-073, revised by D-092 in v0.13). Returns nothing; S-22 reads UpdateState afterwards and treats a still-None state as "up to date". Returns an UpdateCheck since v0.13 (below).
  • Quick row fill (D-075). The D-060 fill lives in :core:design's quickRowCategories(categories, frequent, selected); editors pass the raw frequentCategoryIds[accountId]. An empty or short list is filled from sort_order; the selected category takes the fifth place when absent. This replaces docs/ux/design-spec.md §3.7's interim "selected first, then sort_order" and the v0.9 fallback above.

Changes in v0.13 (D-086 to D-095, as landed by M12e 8c60715):

  • Pairing (D-086). SessionRepository.pair returns OnlineResult<Pairing(user, inviteKind)>, inviteKind read from the E-3 invite_kind; a wire value the app does not know is read as DEVICE, which leads to Home. S-1 goes to S-2 only for InviteKind.PARTNER. Fakes: FakeScenario.pairingInviteKind (null: partner for a member, admin_setup for the admin).
  • Admin name from the invite link (D-087). Not a :core:model type: :feature:onboarding's InviteLink reads the optional from parameter of both link forms into PastedInvite.adminName, :app's AppLink.Invite(serverUrl, code, adminName) carries it to PairingScreen(initialAdminName) and PairingViewModel.prefill(serverUrl, code, adminName). A name that is not a §9.6 display name (1 to 40 code points after trimming, no control characters) or not valid percent-encoding is dropped.
  • SyncSummary.lastAttemptFailed (D-088). True when the last finished cycle started (sync_state.last_attempt_at) after the last success (last_success_at), or none ever succeeded, and no cycle is running; false while one runs. Computed in :data's SyncSummaries (the two fixed-form timestamps compared as text); fakes take FakeScenario.lastAttemptFailed. SyncStatusPill, syncPillVariant and syncLineText dropped their failed parameter; S-16 shows "failed" when online and lastAttemptFailed.
  • Rejection.actor (D-089). The user named by updated_by_user_id of the server's current row stored with the rejection (sync_rejections.server_current, §14.4.3), resolved to a household user; null when the server had no row or the user is unknown, and S-16's stale copy then names the partner. No protocol or Room change.
  • Device.appVersionName (D-090). From the Device wire object's app_version_name (E-7, E-15, E-3); null until the device has sent X-Kvitt-App-Version-Name. :data sends the header on every call, the Coil picture requests included, with the BuildConfig versionName, omitted when it is blank, longer than 32 characters or not printable ASCII.
  • NewRecurringTemplate (D-091). RecurringRepository.create takes the template fields without an id (E-28's body never had one); the server mints the id, which create returns; update keeps taking a RecurringTemplate.
  • UpdateCheck (D-092, revising D-073). check() returns UpToDate (nothing newer published, a 404 from E-5 included, or within 6 hours of the last check that reached the server with nothing newer known), Available(info) or Failed (no answer or another error; state unchanged, and after no answer the next call tries again at once). Debug builds return UpToDate without a request (AC-16.5).
  • SettleState.untouched (D-093). The accounts not in lines where the payer is owed (payerNetOre > 0), with nets that exclude the settlement being edited (AC-11.6), in AccountRepository.all() order; empty for a per-account settle-up. Computed in :data and in :core:fakes alike. Revises D-074's reading of Home balances.
  • PushEvent (D-095). Lives in :core:model (model/Push.kt) as the project's own record; :adapter:fcm keeps PushListener and PushMapping, which maps the FCM data message into it.
  • Pictures (v0.5 reading below) unchanged by assumption: no size parameter, E-11 always at 512.

Readings fixed in v0.5 (no type changes):

  • OnlineResult.Failed("network", null). A REST call that fails on the network (no answer from the server) returns code network, the one token outside §14.3.2, so the UI can show "Could not reach the server" (§9.9) rather than the Offline state, which means the call was not attempted (D-029).
  • No "update required" state. HomeState has no field for a 426. After a 426 protocol_unsupported the phone stops syncing until the installed version code changes (§14.3.1, D-043); the update banner shows only when UpdateState.Available reports a newer published APK. Accepted.
  • Pictures. :data always requests E-11 with size 512 and no size parameter is added to the UI contract: the owner confirmed on 2026-10-18 that the 128 size is not needed on the phone (the server keeps storing both, AC-4.4).
  • Choices documented in :data and accepted: Rejection.summary (the rejected entity's own text) in android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/SettingsRepositories.kt; FieldChange display of splits, allocations and default category names in .../data/repo/Timeline.kt; RecurringTemplate.draft.date in .../data/repo/SettingsRepositories.kt (template mapping).

14.3 OpenAPI 3.1 outline

Milestone M1 writes contracts/openapi.yaml (OpenAPI 3.1.0, info.title Kvitt, info.version 1, one server https://icehack.asuscomm.com:8443, security scheme device = HTTP bearer) from this outline. This outline is binding; the YAML file must not add or drop anything.

14.3.1 Common rules

  • Content type application/json; charset=utf-8 for every request and response body, except: PUT /me/picture (request image/jpeg, image/png or image/webp, raw bytes), GET /users/{user_id}/picture (response image/jpeg), GET /i/{code} (response text/html; charset=utf-8), GET /apk/files/{file_name} (response application/vnd.android.package-archive).
  • Request headers the app sends on every call: X-Kvitt-Protocol: 1, X-Kvitt-App-Version: <versionCode>, X-Kvitt-App-Version-Name: <versionName> (optional for the server; stored per device at E-3 and on the hourly last-seen update, §8.2; blank or longer than 32 characters is ignored; declared on every operation through CommonRules, D-090), Accept-Language: <device locale>, and Authorization: Bearer <token> on authenticated endpoints. A request to an authenticated endpoint without X-Kvitt-Protocol or with a value other than 1 gets 426 protocol_unsupported (the app then stops syncing until its installed version code changes, and shows the update banner when a newer APK is published, §14.2.4). The protocol check comes before the token check.
  • Every response carries X-Kvitt-Server-Version: <crate version>.
  • A known path with a method it does not support gets 405 with an empty body (no error code; the only error response without the JSON error shape), the X-Kvitt-Server-Version header included. An unknown path gets 404 not_found with the JSON error shape.
  • Unknown JSON fields in requests are ignored. Missing required fields or wrong types: 400 invalid_request. Values outside §9.6: 422 validation_failed with field. The §9.6 value limits are not expressed as JSON Schema constraints in contracts/openapi.yaml; the schema fixes types only, and breaking a limit gives 422 validation_failed.
  • Optional fields in responses are present with null, never omitted. (One exception: accepted and rejected results in the POST /sync response have different key sets, as the §14.4.3 example shows.)
  • PATCH bodies: every field optional; absent = unchanged; at least one field required (invalid_request).
  • Path ids that are not a UUID: 404 not_found.

14.3.2 Error shape and error codes

{ "code": "validation_failed", "message": "amount_ore must be between 1 and 1000000000", "field": "amount_ore" }

message is English developer text and is never shown to users; the app maps code (and field) to localised strings. field is the JSON key concerned or null.

Exhaustive list:

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. No longer produced by the server since the server overhaul (6c405e1); it stays in the enum and the contract
unavailable 503 Database busy beyond busy_timeout, or server shutting down

Each endpoint below lists the codes it can return besides the always-possible unauthorized, device_revoked, protocol_unsupported, rate_limited, internal, unavailable (authenticated) or rate_limited, internal, unavailable (unauthenticated).

14.3.3 Wire objects

All server-produced objects. ? marks nullable (always present). Types: string, int (64-bit), bool, ts (fixed RFC 3339), date (YYYY-MM-DD), period (YYYY-MM), uuid.

// User
{ "id": uuid, "role": "admin" | "member", "display_name": string, "picture_version": int?,
  "created_at": ts, "updated_at": ts, "server_version": int }

// Device (only in /me, /devices, /invites/accept)
{ "id": uuid, "user_id": uuid, "name": string, "app_version_code": int?, "app_version_name": string?, "created_at": ts,
  "last_seen_at": ts, "revoked_at": ts?, "is_current": bool }

// Invite (code and link only in the POST /invites response; null in GET /invites)
{ "id": uuid, "kind": "admin_setup" | "partner" | "device", "target_user_id": uuid?,
  "code": string?, "link": string?, "created_at": ts, "expires_at": ts }

// Share (of an account default)
{ "user_id": uuid, "share_bp": int }

// Account
{ "id": uuid, "name": string, "default_shares": [Share, Share], "created_by_user_id": uuid,
  "created_at": ts, "updated_at": ts, "archived_at": ts?, "deleted_at": ts?, "server_version": int }

// Category
{ "id": uuid, "default_key": "groceries" | "restaurants" | "housing" | "home" | "utilities" | "insurance" | "transport" |
  "travel" | "entertainment" | "health" | "children" | "shopping" | "fees" | "other" | null, "name": string?, "icon": string, "color": string,
  "sort_order": int, "created_at": ts, "updated_at": ts, "archived_at": ts?, "deleted_at": ts?, "server_version": int }

// Split (of an expense): share_bp null for mode exact
{ "user_id": uuid, "share_ore": int, "share_bp": int? }

// TemplateSplit: exactly one of share_bp / share_ore non-null; empty array for mode default
{ "user_id": uuid, "share_bp": int?, "share_ore": int? }

// Expense
{ "id": uuid, "account_id": uuid, "category_id": uuid, "amount_ore": int, "description": string, "notes": string,
  "expense_date": date, "payer_user_id": uuid, "split_mode": "default" | "ratio" | "exact" | "full",
  "splits": [Split, Split], "recurring_template_id": uuid?, "recurring_period": period?,
  "created_by_user_id": uuid, "updated_by_user_id": uuid, "created_at": ts, "updated_at": ts,
  "deleted_at": ts?, "server_version": int }

// Allocation
{ "account_id": uuid, "amount_ore": int }

// Settlement
{ "id": uuid, "from_user_id": uuid, "to_user_id": uuid, "amount_ore": int, "settlement_date": date, "note": string,
  "allocations": [Allocation, ...], "created_by_user_id": uuid, "updated_by_user_id": uuid,
  "created_at": ts, "updated_at": ts, "deleted_at": ts?, "server_version": int }

// RecurringTemplate
{ "id": uuid, "account_id": uuid, "category_id": uuid, "amount_ore": int, "description": string, "notes": string,
  "payer_user_id": uuid, "split_mode": "default" | "ratio" | "exact" | "full", "splits": [TemplateSplit, ...],
  "day_of_month": int, "start_period": period, "end_period": period?, "paused": bool,
  "last_generated_period": period?, "next_occurrence_date": date?, "created_by_user_id": uuid,
  "updated_by_user_id": uuid, "created_at": ts, "updated_at": ts, "deleted_at": ts?, "server_version": int }

// AuditEntry
{ "id": uuid, "entity_type": "expense" | "settlement", "entity_id": uuid, "action": "create" | "update" | "delete",
  "source": "app" | "recurring" | "import", "actor_user_id": uuid, "client_at": ts, "server_at": ts,
  "changes": [ { "field": string, "old": any?, "new": any? }, ... ], "server_version": int }

// Error
{ "code": string, "message": string, "field": string? }

Arrays splits are sorted by user_id, allocations by account_id, default_shares by user_id.

14.3.4 Endpoints

E-1 GET /health (none). 200:

{ "status": "ok", "server_version": "0.1.0", "schema_version": 3, "db_ok": true }

schema_version comes from a constant in the server code (the version its embedded migrations produce), not from the database, so /health answers with the database down. When the database check (SELECT 1) fails: 503 with the same shape, status "degraded", db_ok false. The OpenAPI document declares the 503 body as oneOf the health object or Error (an unavailable error from a shutting-down server is also possible).

E-2 GET /setup/status (none). 200 { "initialized": bool }. initialized is true when a user with role admin exists (AC-1.5).

E-3 POST /invites/accept (none). Request:

{ "code": "7K3MQX9D2HPA", "device_name": "Galaxy S26", "display_name": "Jacob" }

display_name is optional in the schema; it is required for admin_setup and partner (invalid_request if missing), ignored for device. 200:

{ "token": "kvt_…", "invite_kind": "admin_setup" | "partner" | "device", "user": User, "device": Device, "household": [User, ...] }

invite_kind is the kind of the accepted invite (D-086); the app shows S-2 only after partner (AC-2.10). The new device's app_version_code and app_version_name come from the request headers (§8.2). Errors: invalid_request, invite_invalid, household_full, validation_failed.

E-4 GET /i/{code}?from=<name> (none). 200 HTML per AC-2.5, for any code string of 1 to 32 characters (validity is not checked). Otherwise 404 HTML, GET /i/ with an empty code included. The optional query parameter from (the inviting admin's display name, set by E-12's link) is percent-decoded (+ as space), trimmed and passed on only into the page's kvitt://invite?server&code&from link, never shown; a value that is not UTF-8, holds a control character or has more than 40 code points is dropped and the page still opens (D-087).

E-5 GET /apk/latest (none). 200 latest.json content (§10.6). Errors: not_found (nothing published).

E-6 GET /apk/files/{file_name} (none). 200 binary with Content-Length. Errors: not_found.

E-7 GET /me (device). 200:

{ "user": User, "device": Device, "household": [User, ...], "public_url": "https://icehack.asuscomm.com:8443" }

E-8 PATCH /me (device). Request { "display_name": string }. 200 User. Errors: invalid_request, validation_failed.

E-9 PUT /me/picture (device). Raw image body. 200 User (new picture_version). Errors: payload_too_large, unsupported_media_type, image_invalid.

E-10 DELETE /me/picture (device). 200 User (picture_version null). Idempotent. Leaves the -removed marker of §10.4 so the next upload gets a new version (D-030).

E-11 GET /users/{user_id}/picture?size=128|512&v=<picture_version> (device). 200 image/jpeg, ETag: "<picture_version>", Cache-Control: private, max-age=31536000, immutable. size defaults to 512; other values invalid_request. v is a cache key only and is ignored. Errors: not_found (unknown user or no picture), invalid_request.

E-12 POST /invites (admin). Request { "kind": "partner" | "device", "target_user_id": uuid? } (target_user_id is optional and nullable in the schema, absent meaning null; it is required for device and must be null for partner). 201 Invite with code (12 characters, no hyphens) and link (<public_url>/i/<code>?from=<percent-encoded admin display name>, D-087; the app also reads links without from). Errors: forbidden, invalid_request, household_full, not_found (target user).

E-13 GET /invites (admin). 200 { "invites": [Invite, ...] } (pending only: not accepted, not cancelled, not expired; code and link null). Errors: forbidden.

E-14 DELETE /invites/{invite_id} (admin). 204. Idempotent for already cancelled. An already accepted invite also answers 204 and nothing changes. Errors: forbidden, not_found.

E-15 GET /devices (device). 200 { "devices": [Device, ...] } ordered by created_at, revoked included (with revoked_at).

E-16 DELETE /devices/{device_id} (device). 204. Revoking an already revoked device is 204. Errors: forbidden, not_found, cannot_revoke_current_device.

E-17 PUT /devices/current/push-token (device). Request { "fcm_token": string? } (the key is required, its value nullable; null clears). A string must be non-empty: "" is invalid_request (D-034); more than 512 characters after trimming is invalid_request with field fcm_token too (D-114, security review L-4). 204. Errors: invalid_request.

E-18 POST /accounts (device). Request:

{ "name": "Vardag", "default_shares": [ { "user_id": "…", "share_bp": 5000 }, { "user_id": "…", "share_bp": 5000 } ] }

201 Account. Errors: invalid_request, validation_failed, name_taken, household_incomplete.

E-19 PATCH /accounts/{account_id} (device). Request { "name"?: string, "default_shares"?: [Share, Share] }. 200 Account. Errors: invalid_request, validation_failed, name_taken, not_found.

E-20 POST /accounts/{account_id}/archive, E-21 POST /accounts/{account_id}/unarchive (device). No body. 200 Account. Idempotent: in the state already held the row is returned unchanged with no new server_version (D-034). Errors: not_found.

E-22 DELETE /accounts/{account_id} (device). 200 Account (tombstone). Errors: not_found, account_not_empty.

E-23 POST /categories (device). Request { "name": string, "icon": string, "color": string }. 201 Category. Errors: invalid_request, validation_failed.

E-24 PATCH /categories/{category_id} (device). Request { "name"?: string | null, "icon"?: string, "color"?: string, "sort_order"?: int } (name: null resets a default category's name). 200 Category. Reordering several categories is one PATCH per category whose sort_order changes. Errors: invalid_request, validation_failed, not_found, category_is_default.

E-25 POST /categories/{category_id}/archive, E-26 POST /categories/{category_id}/unarchive (device). 200 Category. Idempotent as E-20 and E-21 (D-034). Errors: not_found.

E-27 DELETE /categories/{category_id} (device). 200 Category (tombstone). Errors: not_found, category_in_use, category_is_default.

E-28 POST /recurring-templates (device). Request: the RecurringTemplate fields account_id, category_id, amount_ore, description, notes, payer_user_id, split_mode, splits (TemplateSplit, rules of §9.1: none for default; two bp entries for ratio and full; two öre entries summing to the amount for exact), day_of_month, start_period, end_period, paused; no id: the server mints it and returns it in the 201 body (D-091). 201 RecurringTemplate. If a period is already due (§9.5), the server generates it before responding. Archived accounts and categories are accepted (AC-6.4). Errors: invalid_request, validation_failed (also for an unknown or deleted account_id or category_id, with field), household_incomplete.

E-29 PATCH /recurring-templates/{template_id} (device). Any subset of the E-28 fields; split_mode and splits must be sent together. 200 RecurringTemplate. Errors: invalid_request, validation_failed, not_found.

E-30 DELETE /recurring-templates/{template_id} (device). 200 RecurringTemplate (tombstone). Errors: not_found.

E-31 GET /sync?since=<int>&limit=<int> (device). See §14.4.2. since is required (missing or negative: invalid_request); limit is optional. Errors: invalid_request, cursor_ahead.

E-32 POST /sync (device). See §14.4.3. Errors (whole request): invalid_request, batch_too_large, payload_too_large, household_incomplete.

E-33 POST /settlements/proposal (device). Request { "payer_user_id": uuid, "amount_ore": int, "exclude_settlement_id": uuid? } (exclude_settlement_id optional and nullable, absent meaning null). 200:

{ "allocations": [ { "account_id": "…", "amount_ore": 30000 } ], "excess_ore": 0, "over_settled_account_ids": [] }

Computed with kvitt-domain::propose_allocation over the server's non-deleted rows. Errors: invalid_request, validation_failed, no_accounts.

14.3.5 Examples as contract

M1 writes one JSON example per request and response body above, plus one per error code, in contracts/examples/<E-id>-<name>/{request,response}.json and contracts/examples/errors/<code>.json, and the sync examples of §14.4. Directory names are E-<n>-<kebab-name> (for example E-1-health, E-32-sync-push). The server's tests serialise its responses for the same fixtures and compare; the app's :data tests decode every example into its DTOs and re-encode it, comparing JSON values. The server's test harnesses seed the two users after the fourteen category seeds, so the users have server_version 15 and 16; E-7-me/response.json shows those values and its test compares them (D-058).

14.4 Sync protocol v1

14.4.1 Principles

  • The server is the authority. The phone keeps a full copy and a cursor.
  • Every synced row carries server_version from one global sequence (§14.1). The cursor is the highest server_version the phone has applied.
  • Phones push only expenses and settlements. Everything else changes through REST and comes back through pull (the REST response is also written locally at once).
  • Conflict rule: per row, last write wins on the client updated_at; ties go to the server's copy; deletes are final and always win.
  • Pushes are idempotent per mutation_id.
  • Tombstones are kept forever on both sides and are synced like any other row.
  • A sync cycle is: push all pending rows (in batches), then pull until has_more is false. One cycle at a time per process (a Mutex in the sync engine; WorkManager unique work names kvitt-sync for one-off work, policy APPEND_OR_REPLACE, and kvitt-sync-periodic for the periodic work, policy KEEP; WorkManager cannot share one unique name between periodic and one-off work, D-028). Triggers in AC-15.3; pushes go in batches of 100, pulls in pages of 500.

14.4.2 Pull: GET /sync?since=N&limit=L

Server, in one read transaction per page (so the cursor check and the page see one snapshot):

  1. N missing or negative, or L outside 1 to 1000: 400 invalid_request.
  2. If N > current sync_sequence.value: 409 cursor_ahead.
  3. Select the L (default 500, max 1000) rows with the lowest server_version > N across users, categories, accounts, recurring_templates, expenses, settlements, audit_log (a UNION ALL of (server_version, table, id) ordered by server_version), including tombstones. An expense with its splits, a settlement with its allocations, an account with its default shares and a template with its splits each count as one row.
  4. Respond 200:
{
  "epoch": "30b85fb529aac0016dc91650841005b9",
  "since": 0,
  "next_since": 512,
  "has_more": true,
  "server_time": "2026-10-17T12:00:00.000Z",
  "users": [User],
  "categories": [Category],
  "accounts": [Account],
  "recurring_templates": [RecurringTemplate],
  "expenses": [Expense],
  "settlements": [Settlement],
  "audit_entries": [AuditEntry]
}

next_since is the highest server_version in the page, or N when the page is empty. has_more is true when rows with server_version > next_since exist. Every array is present (possibly empty) and sorted by server_version.

Phone, per page, in one Room transaction:

  1. If sync_state.epoch is non-null and differs from epoch: discard the page and run epoch recovery (§14.4.8).
  2. Apply arrays in this order: users, categories, accounts, recurring_templates, expenses, settlements, audit_entries.
  3. For each row: if the local row exists and has server_version ≥ the incoming one, skip. For expenses and settlements: if the local row is pending, skip (the push will resolve it). Otherwise upsert the row, replace its child rows, set local_updated_at. Audit entries are insert-or-ignore.
  4. Set sync_state.cursor = next_since, sync_state.epoch = epoch.

Repeat while has_more. Then set last_success_at.

A 409 cursor_ahead answer to a pull is treated as epoch recovery (§14.4.8): cursor 0, epoch cleared, every local expense and settlement marked pending with a new pending_mutation_id, and the cycle restarted (D-026).

14.4.3 Push: POST /sync

Request (1 to 100 mutations, body ≤ 1 MiB):

{
  "mutations": [
    {
      "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8d9e",
      "entity_type": "expense",
      "entity": {
        "id": "01929c5e-1111-7aaa-8bbb-000000000001",
        "account_id": "…", "category_id": "…", "amount_ore": 41200, "description": "ICA Maxi", "notes": "",
        "expense_date": "2026-10-16", "payer_user_id": "…", "split_mode": "default",
        "splits": [ { "user_id": "…", "share_ore": 20600, "share_bp": 5000 }, { "user_id": "…", "share_ore": 20600, "share_bp": 5000 } ],
        "created_at": "2026-10-16T17:02:11.123Z", "updated_at": "2026-10-16T17:02:11.123Z", "deleted_at": null
      }
    },
    {
      "mutation_id": "…",
      "entity_type": "settlement",
      "entity": {
        "id": "…", "from_user_id": "…", "to_user_id": "…", "amount_ore": 50000, "settlement_date": "2026-10-16", "note": "",
        "allocations": [ { "account_id": "…", "amount_ore": 50000 } ],
        "created_at": "…", "updated_at": "…", "deleted_at": null
      }
    }
  ]
}

The pushed entity contains exactly the client-writable fields: for expenses id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, split_mode, splits, created_at, updated_at, deleted_at; for settlements id, from_user_id, to_user_id, amount_ore, settlement_date, note, allocations, created_at, updated_at, deleted_at. Server-owned fields (server_version, created_by_user_id, updated_by_user_id, recurring_template_id, recurring_period) are ignored if sent. A delete is the full entity with deleted_at set. Every pushed id (mutation_id, entity.id and every id inside the entity) must be a UUID in the canonical form of the conventions (lowercase, hyphenated, 36 characters); another spelling of a valid UUID is not accepted (D-037).

Response 200, one result per mutation in request order:

{
  "server_time": "2026-10-17T12:00:00.000Z",
  "results": [
    { "mutation_id": "…", "entity_type": "expense", "entity_id": "…", "status": "accepted", "server_version": 913, "entity": Expense },
    { "mutation_id": "…", "entity_type": "settlement", "entity_id": "…", "status": "rejected", "reason": "stale",
      "message": "a newer version exists", "current": Settlement }
  ]
}

entity and current are full wire objects (§14.3.3) or, for current, null when the server has no row with that id. Accepted and rejected results have different key sets exactly as in the example (accepted: mutation_id, entity_type, entity_id, status, server_version, entity; rejected: mutation_id, entity_type, entity_id, status, reason, message, current); this overrides the "present with null" rule of §14.3.1 for these objects. server_version is the row's version after processing.

14.4.4 Server processing of one mutation

The whole request runs in one BEGIN IMMEDIATE transaction; each mutation in its own SAVEPOINT, rolled back on rejection. Pushes are enqueued after commit. Before the transaction, the whole request is checked: 0 or more than 100 mutations is 422 batch_too_large (D-036); a mutation whose mutation_id or entity.id is not a canonical UUID, or whose entity_type is not expense or settlement, makes the whole request fail with 400 invalid_request (without these keys no per-mutation result can be formed, D-035). Steps, in order:

  1. Replay. If mutation_id exists in sync_mutations, return the stored result unchanged (no other effect).
  2. Household. Fewer than two users: the whole request fails with 409 household_incomplete before any mutation.
  3. Shape. Every other shape error (missing fields, wrong types, unknown enum values, ids that are not canonical UUIDs, timestamps not in the fixed form): rejected, validation_failed, per mutation (D-035).
  4. Clock. updated_at > server now + 10 minutes: rejected, clock_skew.
  5. Load the stored row by id.
  6. Stored row is a tombstone. If the pushed entity is also a delete: accepted, no change, no audit, no push (idempotent delete). Otherwise rejected, deleted, current = the tombstone.
  7. Pushed entity is a delete (deleted_at non-null):
    • Stored row exists: set deleted_at = pushed deleted_at, updated_at = the later of stored and pushed updated_at, keep every other stored field, updated_by_* from the caller, new server_version, audit delete with changes [{"field":"deleted_at","old":null,"new":<deleted_at>}], push *_deleted. accepted. This applies even when the pushed updated_at is older (deletes always win).
    • No stored row (created and deleted before it ever synced): run the full validation of step 8 (field rules, domain rules and references), so the tombstone satisfies the schema; on failure rejected as in step 8. Otherwise insert the row as a tombstone with a new server_version, no audit, no push. accepted (D-037).
  8. Validate, in this order: the field rules of §9.6, then the domain rules of §9.1 (expenses) or §9.4 settlement validity (settlements), then references: account_id, every allocation account_id, category_id must exist and not be deleted (reference_not_found otherwise; archived is fine); user ids must be household users (validation_failed). Failure: rejected with that reason, current = stored row or null.
  9. No stored row: insert with created_by_user_id = updated_by_user_id = caller's user, updated_by_device_id = caller's device, server_received_at = now, new server_version; audit create with every diff field (old null); push *_created. accepted.
  10. Stored row exists, pushed updated_at ≤ stored updated_at: rejected, stale, current = stored row.
  11. Stored row exists, newer: compute the diff (§14.4.7). Empty diff: change nothing (not even updated_at), no audit, no push, accepted with the stored row and its existing server_version. Otherwise update fields and children, updated_by_* from the caller, new server_version, audit update with the diff, push *_updated. accepted. recurring_template_id, recurring_period, created_by_user_id and created_at keep their stored values (a pushed created_at is ignored on update, D-037).
  12. Store (mutation_id, device_id, entity_type, entity_id, result JSON, now) in sync_mutations, for accepted and rejected results alike (step 1 then replays it).

Every rejection, at steps 3 and 4 too, carries current = the stored row with that id, or null when there is none (D-037).

14.4.5 Phone push procedure

  1. Read pending expenses and settlements ordered by local_updated_at ascending; take up to 100. Build each mutation from the row with mutation_id = pending_mutation_id.
  2. POST /sync. On a network error or 5xx: stop the cycle, keep everything pending, let WorkManager retry with exponential backoff starting at 30 seconds. On 401 device_revoked: AC-3.3. On 401 unauthorized: SessionState.Unrecognised (§14.4.8). On 429: retry as for 5xx. On 426: stop syncing until the installed version code changes (§14.3.1). A push refused as a whole (400 invalid_request, 413 payload_too_large, 409 household_incomplete, 422 batch_too_large) counts as a batch with no progress: the cycle goes on to pull and records the code in sync_state.last_error_code (D-040).
  3. Apply results in one Room transaction:
    • accepted: if the row's pending_mutation_id still equals the sent mutation_id, overwrite the local row with entity, set pending = false, pending_mutation_id = null. If it differs (edited again during the request), set only server_version, created_by_user_id, updated_by_user_id from entity and leave it pending.
    • rejected with current equal to the pushed entity in every client-writable field: treat as accepted with current (no rejection record). This covers re-pushes of an already stored state.
    • other rejected: if pending_mutation_id still matches, overwrite the local row with current (or delete the local row and its children if current is null), set pending = false, and insert a sync_rejections row with the pushed entity as rejected_payload. If it no longer matches, insert the rejection record only and leave the newer local edit pending. Reason clock_skew: do not overwrite and do not clear pending; give the row a new pending_mutation_id (the server stored the rejection under the old one and would replay it), set sync_state.clock_skew = true and stop the cycle (D-027). Re-stamp (D-094, revising D-027 as implemented by M12e): when sync_state.clock_skew is set at the start of a cycle, every pending expense and settlement whose updated_at is later than the phone's clock now gets updated_at = now (and a tombstone's deleted_at too, when it is later than now), in one Room transaction before pushing; rows stamped no later than now are untouched, so a row stamped by a clock that is still ahead is refused again until the clock is fixed, and a corrected clock heals it. Room schema unchanged.
  4. Repeat until no pending rows remain or a batch made no progress, then pull.

14.4.6 Rejection reasons

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 "; is Rejection.actor, the updated_by_user_id of the stored current (sync_rejections.server_current), the partner when unknown (D-089)
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"

For every reason S-16 can show what was lost: Rejection.lost is decoded from sync_rejections.rejected_payload and shown read-only (§13.3 S-16, D-109).

14.4.7 Audit diff

Diff fields and their JSON value types:

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)
  • create: one change per field above except deleted_at, old null, in the table's order.
  • update: one change per field whose JSON value differs, in the table's order.
  • delete: exactly the deleted_at change.
  • client_at = the pushed updated_at (for delete, the pushed deleted_at); server_at = server now. Scheduler and importer use server now for both.

The phone renders changes with ids resolved to names (FR-12).

14.4.8 Epoch recovery and re-pairing

  • sync_epoch changes only when the owner runs rotate-epoch after a restore (§10.9).
  • When a pull returns a different epoch, the phone, in one transaction: sets cursor = 0, stores the new epoch, and marks every local expense and settlement (tombstones included) pending with a new pending_mutation_id, keeping updated_at. It then runs a normal cycle: push everything (rows the restored server lacks are inserted, rows it has with older updated_at are updated, identical rows come back as stale with equal content and are silently accepted per §14.4.5), then a full pull.
  • Devices paired after the restored backup do not exist on the restored server; their requests get 401 unauthorized. The app keeps its local data and shows S-1 with "Pair this phone again". After pairing with a new invite: if the user id equals the previous one, the local data is kept and the epoch-recovery push runs; otherwise local data is wiped first.

14.4.9 Limits and timing

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

With these values a phone offline for 14 days loses nothing: tokens do not expire, cursors stay valid (versions only grow), and idempotency records outlive any realistic retry.

14.5 Domain rules as pure functions

Implemented twice: Kotlin in :domain (package se.hagstedt.kvitt.domain, input and output types in :core:model package se.hagstedt.kvitt.model.money) and Rust in kvitt-domain. Both are pure (no I/O, no clock, no randomness, no locale) and must pass every vector in contracts/test-vectors/. Analytics and the category suggestion (§14.5.6) exist only in Kotlin; next_occurrence_date and the day clamping exist only in Rust (D-015). The rules themselves are §9.1, §9.3 and §9.4; this section fixes signatures, error tokens and the vector format.

14.5.1 Types

Ids are plain strings in domain inputs (the vectors use short ids like "a", "b", "x"; implementations must not assume UUID format). Money is Long / i64 öre; bp is Int / u32. Because bp is u32 in Rust, negative bp is unrepresentable there and the vectors contain none; bp_out_of_range means a value above 10000. All domain functions, analytics included, receive only non-deleted rows; excluding tombstones is the caller's job and is tested in :data and the server, not in the vectors.

Kotlin (:core:model, package se.hagstedt.kvitt.model.money):

sealed interface SplitSpec {
    data class Ratio(val bp: Map<String, Int>) : SplitSpec      // modes default and ratio
    data class Exact(val ore: Map<String, Long>) : SplitSpec    // mode exact
    data class Full(val bearerUserId: String) : SplitSpec       // mode full
}
enum class SplitError { AMOUNT_OUT_OF_RANGE, USERS_INVALID, PAYER_UNKNOWN, BP_OUT_OF_RANGE, BP_SUM_NOT_10000, EXACT_NEGATIVE, EXACT_SUM_MISMATCH, BEARER_UNKNOWN, SHARE_USERS_MISMATCH }
sealed interface SplitResult {
    data class Ok(val shares: Map<String, Long>) : SplitResult
    data class Err(val error: SplitError) : SplitResult
}

data class BalanceExpense(val accountId: String, val amountOre: Long, val payerUserId: String, val shares: Map<String, Long>)
data class BalanceSettlement(val fromUserId: String, val toUserId: String, val allocations: Map<String, Long>)   // accountId -> öre
data class Balances(val perAccount: Map<String, Map<String, Long>>, val global: Map<String, Long>)              // accountId -> userId -> net

data class AccountPosition(val accountId: String, val createdAt: String, val archived: Boolean, val payerNetOre: Long)
enum class AllocationError { AMOUNT_OUT_OF_RANGE, NO_ACCOUNTS }
data class Allocation(val accountId: String, val amountOre: Long)
sealed interface AllocationResult {
    data class Ok(val allocations: List<Allocation>, val excessOre: Long, val overSettledAccountIds: List<String>) : AllocationResult
    data class Err(val error: AllocationError) : AllocationResult
}

enum class SettlementError { AMOUNT_OUT_OF_RANGE, SAME_USER, USER_UNKNOWN, NO_ALLOCATIONS, TOO_MANY_ALLOCATIONS, ALLOCATION_NOT_POSITIVE, DUPLICATE_ACCOUNT, ALLOCATION_SUM_MISMATCH }

Rust (kvitt-domain):

pub enum SplitSpec { Ratio { bp: BTreeMap<String, u32> }, Exact { ore: BTreeMap<String, i64> }, Full { bearer_user_id: String } }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")]
pub enum SplitError { AmountOutOfRange, UsersInvalid, PayerUnknown, BpOutOfRange,
    #[serde(rename = "bp_sum_not_10000")] BpSumNot10000,   // rename_all alone gives "bp_sum_not10000" (D-006)
    ExactNegative, ExactSumMismatch, BearerUnknown, ShareUsersMismatch }

pub struct BalanceExpense { pub account_id: String, pub amount_ore: i64, pub payer_user_id: String, pub shares: BTreeMap<String, i64> }
pub struct BalanceSettlement { pub from_user_id: String, pub to_user_id: String, pub allocations: BTreeMap<String, i64> }
pub struct Balances { pub per_account: BTreeMap<String, BTreeMap<String, i64>>, pub global: BTreeMap<String, i64> }

pub struct AccountPosition { pub account_id: String, pub created_at: String, pub archived: bool, pub payer_net_ore: i64 }
pub enum AllocationError { AmountOutOfRange, NoAccounts }
pub struct Allocation { pub account_id: String, pub amount_ore: i64 }
pub struct AllocationProposal { pub allocations: Vec<Allocation>, pub excess_ore: i64, pub over_settled_account_ids: Vec<String> }

pub enum SettlementError { AmountOutOfRange, SameUser, UserUnknown, NoAllocations, TooManyAllocations, AllocationNotPositive, DuplicateAccount, AllocationSumMismatch }

14.5.2 Functions

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

Semantics beyond §9:

  • computeSplit: userIds must have exactly 2 distinct entries (users_invalid); payerUserId must be one of them (payer_unknown); the Ratio/Exact map keys must equal userIds as a set (share_users_mismatch); Full.bearerUserId must be one of them (bearer_unknown); amount outside 1 to 1 000 000 000 is amount_out_of_range. Errors are checked in the order of the SplitError enum and the first failure is returned. The result map has both user ids. Full is computed as Ratio with bearer 10000 (so payer remainder rules apply trivially).
  • computeBalances: inputs contain only non-deleted rows. perAccount has an entry for every id in accountIds (zeros included) and for any other account id referenced; each inner map has every id of userIds. global sums perAccount. Expenses' and settlements' account ids not in accountIds still count.
  • proposeAllocation: amountOre outside 1 to 1 000 000 000 (§9.6) is amount_out_of_range. accounts are all non-deleted accounts with the payer's net per account (callers compute nets with computeBalances, excluding the settlement being edited). Errors in enum order. Allocations are in the order produced by §9.4. overSettledAccountIds lists the account that received excessOre (empty when excessOre is 0).
  • validateSettlement: errors in enum order; null / Ok(()) when valid. Reference existence is not checked here (the server checks it, §14.4.4).
  • overSettledAccounts: ids, in allocation order, whose allocation exceeds max(0, −payerNetOre); an allocation for an account not in accounts counts as net 0.
  • Recurring dates (Rust only): the clamped date of period YYYY-MM for day_of_month 1 to 31 is that day, or the last day of the month when the month is shorter (§9.5, AC-9.2). next_occurrence_date takes the template's day_of_month, start_period, end_period, last_generated_period, paused and deleted state and returns the date defined in §9.5 (null when paused, deleted or past end_period). Pure: no clock, no time zone. No vector file covers them; M2a's unit tests cover every clamping case of AC-9.2 and the null cases.

14.5.3 Error tokens on the wire and in vectors

Snake-case of the enum names: amount_out_of_range, users_invalid, payer_unknown, bp_out_of_range, bp_sum_not_10000, exact_negative, exact_sum_mismatch, bearer_unknown, share_users_mismatch, no_accounts, same_user, user_unknown, no_allocations, too_many_allocations, allocation_not_positive, duplicate_account, allocation_sum_mismatch. The server puts the token in the message of validation_failed errors and rejections; the phone uses it as FieldError.error. The token for a wrong bp sum is exactly bp_sum_not_10000 on both sides (D-006): the Rust variant carries an explicit #[serde(rename = "bp_sum_not_10000")], and the Kotlin side must emit the same string (the lowercase of BP_SUM_NOT_10000), never a token derived by a generic camel-to-snake conversion.

14.5.4 Analytics (Kotlin only)

Types in :core:model, package se.hagstedt.kvitt.model.analytics (D-017); functions in :domain.

data class AnalyticsExpense(val date: LocalDate, val accountId: String, val categoryId: String, val amountOre: Long, val shares: Map<String, Long>)
data class DateRange(val start: LocalDate, val endInclusive: LocalDate)
enum class AnalyticsGranularity { MONTH, WEEK }
sealed interface AnalyticsMeasure { data object Household : AnalyticsMeasure; data class Share(val userId: String) : AnalyticsMeasure }
enum class AnalyticsGroupBy { CATEGORY, ACCOUNT }
data class Bucket(val key: String, val start: LocalDate, val endInclusive: LocalDate, val totals: Map<String, Long>, val total: Long)
data class SpendSeries(val buckets: List<Bucket>, val groupTotals: Map<String, Long>, val grandTotal: Long)

fun spendSeries(expenses: List<AnalyticsExpense>, range: DateRange, granularity: AnalyticsGranularity, measure: AnalyticsMeasure, groupBy: AnalyticsGroupBy): SpendSeries
fun resolveRange(preset: RangePreset, today: LocalDate, earliestExpense: LocalDate?, customStart: LocalDate?, customEnd: LocalDate?): DateRange

(LocalDate is kotlinx.datetime.LocalDate. AnalyticsMeasure.Share and se.hagstedt.kvitt.model.Share share a simple name; accepted, §14.2.4.)

  • spendSeries: expenses outside the range are ignored. Buckets cover the range completely, in order, including empty ones. Month keys YYYY-MM, week keys YYYY-Www (ISO week-numbering year, week 1 contains the first Thursday). A bucket's start/endInclusive are clipped to the range. totals maps group id (category id or account id) to öre, omitting zero groups; total is their sum. Measure Household adds amountOre; Share(u) adds shares[u] (0 if absent). groupTotals sums over all buckets, omitting zeros.
  • resolveRange: THIS_MONTH = first of the current month to today; LAST_3_MONTHS, LAST_6_MONTHS, LAST_12_MONTHS = first day of the month N−1 months before the current month to today; THIS_YEAR = 1 January to today; ALL_TIME = earliestExpense (or today when null) to today; CUSTOM = the custom dates, swapped if start > end. Expenses dated after today are therefore excluded from presets; a custom range may include future dates. CUSTOM with a null customStart or customEnd throws IncompleteCustomRangeException, a subclass of IllegalArgumentException declared in :domain (D-011); the caller (the AnalyticsRepository implementation in :data, and its fake) catches it and maps it to validation_failed (D-018; no vector covers it; in practice the repository resolves a missing custom date as today before calling, §14.2.4); ALL_TIME with earliestExpense after today gives the range today..today.

14.5.5 Test-vector format

One file per function in contracts/test-vectors/: compute_split.json, compute_balances.json, propose_allocation.json, validate_settlement.json, over_settled_accounts.json, spend_series.json (Kotlin only), resolve_range.json (Kotlin only), suggest_category.json (Kotlin only, D-049).

{
  "format": 1,
  "function": "compute_split",
  "cases": [
    {
      "name": "50/50 odd amount, payer gets the extra öre",
      "input": {
        "amount_ore": 1001, "payer_user_id": "a", "user_ids": ["a", "b"],
        "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } }
      },
      "expected": { "ok": { "a": 501, "b": 500 } }
    },
    {
      "name": "ratio does not sum to 10000",
      "input": {
        "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"],
        "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 4000 } }
      },
      "expected": { "error": "bp_sum_not_10000" }
    }
  ]
}

Input keys are the snake_case parameter names of the Rust signatures. For the Kotlin-only analytics functions they are the snake_case names of the Kotlin parameters and fields: spend_series takes expenses ([{"date", "account_id", "category_id", "amount_ore", "shares"}]), range ({"start", "end_inclusive"}), granularity, measure, group_by; resolve_range takes preset, today, earliest_expense, custom_start, custom_end; suggest_category takes description (string), history ([{"description", "category_id", "expense_date", "created_at"}]), keywords ([{"keyword", "match", "default_key"}] in table order, match one of token, prefix, phrase), active_categories ([{"id", "default_key"}], default_key null for a custom category) and fallback (category id or null), exactly as in contracts/test-vectors/suggest_category.json (aa765bb); the unicode parameter has no key (the test binds the platform implementation). spec is {"mode":"ratio","bp":{…}}, {"mode":"exact","ore":{…}} or {"mode":"full","bearer_user_id":"…"}. Outputs: compute_split {"ok": {user: öre}}; compute_balances {"ok": {"per_account": {...}, "global": {...}}}; propose_allocation {"ok": {"allocations": [{"account_id","amount_ore"}], "excess_ore": n, "over_settled_account_ids": [...]}}; validate_settlement {"ok": null}; over_settled_accounts {"ok": [ids]}; spend_series {"ok": {"buckets": [{"key","start","end_inclusive","totals","total"}], "group_totals": {...}, "grand_total": n}} with measure input "household" or {"share": "a"}, and dates as strings; resolve_range {"ok": {"start","end_inclusive"}}; suggest_category {"ok": "<category id>"} or {"ok": null} (it has no error case). Errors: {"error": "<token>"}.

Minimum cases M1 must include (more are welcome):

  • compute_split: every row of the §9.1 example table; full on payer; full on non-payer; exact valid; exact sum mismatch; exact negative; amount 0; amount 1 000 000 001; amount 1 000 000 000 with 3333 bp; bp 10001; three user ids; payer not in users; map keys not matching users; one error case per SplitError value.
  • compute_balances: empty; one account with the two expenses (1001, payer a, 50/50) and (100, payer b, a 6000 bp) giving a +440, b −440, then adding settlement b → a 440 giving zeros; two accounts with opposite signs and a global sum; an account in account_ids with no rows (zeros).
  • propose_allocation: payer owes in accounts X (created 2025-01-01T00:00:00.000Z, net −30000), Y (2025-02-01…, −20000) and is owed in Z (2024-12-01…, +10000): amount 40000 gives X 30000, Y 10000, excess 0; amount 60000 gives X 30000, Y 30000, excess 10000, over-settled [Y]; amount 5000 with only Z gives Z 5000 over-settled [Z]; all accounts archived and none owing gives the oldest; equal created_at ordered by id; no accounts gives no_accounts; amount 0 gives amount_out_of_range.
  • validate_settlement: one case per SettlementError value plus a valid case with 3 allocations and one with 50.
  • over_settled_accounts: none, one, an unknown account.
  • spend_series: monthly and weekly over a range crossing a year boundary (ISO week 2026-W53 exists: 28 Dec 2026 to 3 Jan 2027), household and share measures, category and account grouping, empty buckets, edge-clipped buckets.
  • resolve_range: every preset with today = 2026-10-17, ALL_TIME with and without an earliest expense, CUSTOM swapped.
  • suggest_category (M12a): empty and punctuation-only descriptions (fallback); exact history match across case, ', ’, : and other punctuation ("Willy:s" and "WILLYS"); most often beats most recent; ties broken by expense_date, then created_at, then category id; first-token match when no exact match; exact match taking precedence over first token; an archived category in history skipped (the next category, then the next step); token, prefix and phrase keywords; longest keyword wins; equal length, earlier entry wins; a keyword whose category is archived falls through to the next matching entry; a renamed default category found by its key; diacritics kept (entre and entré distinct); & and digits kept (h&m, okq8); NFC applied to non-NFC input; surrogate pairs counted as one code point; no match gives fallback; fallback null gives null. M12a's file has 65 cases.

14.5.6 Category suggestion (Kotlin only)

The rule is §9.11 (D-049). Types and functions in :domain, package se.hagstedt.kvitt.domain (landed with M12a, aa765bb). The records live in :domain, not in :core:model: the keyword table is data, not a UI contract, and default keys are open strings there.

data class SuggestionHistoryEntry(val description: String, val categoryId: CategoryId, val expenseDate: LocalDate, val createdAt: String)
enum class KeywordMatch { TOKEN, PREFIX, PHRASE }
data class CategoryKeyword(val keyword: String, val match: KeywordMatch, val defaultKey: String)
data class ActiveCategory(val id: CategoryId, val defaultKey: String?)
interface UnicodeText {
    fun nfc(text: String): String
    fun isLetter(codePoint: Int): Boolean   // general category Lu, Ll, Lt, Lm or Lo
    fun isDigit(codePoint: Int): Boolean    // general category Nd
}

fun normalizeDescription(text: String, unicode: UnicodeText): String   // CS-4
fun parseCategoryKeywords(csv: String): List<CategoryKeyword>          // the §14.8 table; D-056
fun suggestCategory(description: String, history: List<SuggestionHistoryEntry>, keywords: List<CategoryKeyword>, activeCategories: List<ActiveCategory>, fallback: CategoryId?, unicode: UnicodeText): CategoryId?

(CategoryId is se.hagstedt.kvitt.model.CategoryId; LocalDate is kotlinx.datetime.LocalDate.)

  • UnicodeText exists because pure modules may not use java.* and kotlin.* has neither NFC nor general categories. :data binds it with java.text.Normalizer (NFC) and java.lang.Character (getType for L and Nd); the :domain tests bind the same. normalizeDescription applies nfc, then lowercase(), then walks the string by code points (a surrogate pair is one code point), deleting ', ’ and :, keeping &, letters and digits, and turning every other code point into a space; then it collapses spaces and trims. Inputs need not be NFC; the vectors include non-NFC strings.
  • history holds non-deleted expenses only (the caller's job, as for every domain function). Expenses whose categoryId is not in activeCategories are ignored (CS-5).
  • History ranking (CS-5 steps 1 and 2): the highest count wins; on equal count, the category whose latest matching expense is latest by (expenseDate, then createdAt); then the lower category id (string order).
  • Keyword ranking (CS-6): among the matching entries, the longest keyword in code points wins, then the earlier entry in table order (keywords are passed in table order, §14.8). An entry resolves to the active category with that defaultKey; none means the next entry in that ranking is tried.
  • parseCategoryKeywords (D-056) reads the text of contracts/category-keywords.csv: header keyword,match,default_key,source, entries in file order (table order), match mapped to KeywordMatch; a malformed table (wrong header, wrong column count, unknown match) throws IllegalArgumentException. Pure; shared by :data and :core:fakes, which read the bundled resource (§14.2.4, D-055).
  • fallback is the step-4 category id (AC-8.6), computed by the caller; it is returned as given when no earlier step applies, also when the normalised description is empty.

14.6 Push payload v1

FCM HTTP v1 message the server sends, one per target device (POST https://fcm.googleapis.com/v1/projects/kvitt-1337/messages:send):

{
  "message": {
    "fid": "<device fcm_token>",
    "android": { "priority": "high", "ttl": "86400s" },
    "data": {
      "v": "1",
      "type": "expense_created",
      "entity_type": "expense",
      "entity_id": "01929c5e-1111-7aaa-8bbb-000000000001",
      "actor_user_id": "0192…",
      "count": "1"
    }
  }
}

message.fid holds the device's fcm_token, which is the Firebase Installation ID the phone registered (D-083, D-096). FCM HTTP v1 deprecates message.token ("Use fid instead"); token, fid, topic and condition are mutually exclusive, and the server sets only fid. The database column and the E-17 field keep the name fcm_token. contracts/examples/push/*.json use fid.

Data-only messages (no notification block), all values strings. Types (exhaustive):

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

Server sending rules:

  • Sending happens on one background task fed by a bounded queue (capacity 100; when full, the oldest message is dropped with a warning log). It never delays an HTTP response.
  • Authentication: the sender signs an RS256 JWT with header kid = the service account's private_key_id, exchanges it for an access token and caches the token until 5 minutes before its expiry (§6.3).
  • Retries (D-038): one send plus up to three retries after 1 s, 5 s and 25 s (at most four sends) on 429, 5xx and network errors. On 401 the cached access token is dropped and that message is not retried; other 4xx are not retried. Retries run inside the single sender task, so one failing device can hold the queue for up to 31 s; accepted for two users.
  • FCM UNREGISTERED or HTTP 404 for a token clears that device's fcm_token, only if the stored token is still the one that was sent (a token the phone replaced meanwhile is kept).
  • Push is disabled (NoopPushQueue, one start-up warning) when the credentials file is missing or unusable (treated alike).

Phone handling (:adapter:fcm's PushMapping maps the message to PushEvent(type, entityType, entityId, actorUserId, count), a :core:model type in model/Push.kt since D-095; :app handles it in an expedited WorkManager job, D-085):

  1. Call syncForPush and wait for it up to 20 seconds.
  2. Build the notification text from Room per §13.3 copy (or the generic text if the sync failed or the entity or its actor is missing locally). Channel id activity, name "Activity" / "Aktivitet", importance default. Notification id = hash of entity_id (so an update replaces the create's notification); sync_batch uses id 1.
  3. Tap opens S-8 or S-10 for the entity; Home for deletions, sync_batch and the generic notification (D-085).
  4. Unknown type or v other than 1: sync only, no notification.

14.7 Module layout and ownership map

Repository layout (paths are binding):

SPEC.md  CLAUDE.md  README-agents.md  PLAN-*.md
.gitea/workflows/      (Gitea Actions image build, M2c-pi)
contracts/
  openapi.yaml
  category-keywords.csv
  schema/server-v1.sql  schema/migrations/0002_categories_v2.sql
  test-vectors/*.json
  examples/<E-id>-<name>/{request,response}.json, examples/errors/<code>.json, examples/sync/*.json, examples/push/*.json
docs/  HANDOFF.md  decisions.md  ux/  spikes/  validation/
prompts/
tools/  gradle-serial.sh  clean-builds.sh  install-git-hooks.sh  git-hooks/  dependency-report/  contracts-check.py
server/
  Cargo.toml (workspace)  Cargo.lock  Dockerfile  rust-toolchain.toml
  crates/kvitt-domain/  src/{lib.rs, split.rs, balance.rs, allocation.rs, settlement.rs, recurring.rs}  tests/vectors.rs
  crates/kvitt-server/  migrations/{0001_init.sql, 0002_categories_v2.sql, 0003_device_version_name.sql}
                        src/{main.rs, lib.rs, config.rs, error.rs, router.rs, openapi.rs, auth.rs, time.rs}
                        src/db/{mod.rs, pool.rs, sequence.rs}
                        src/wire/{mod.rs, user.rs, device.rs, invite.rs, account.rs, category.rs, expense.rs, settlement.rs, recurring.rs, audit.rs, sync.rs}
                        src/api/{mod.rs, health.rs, setup.rs, invites.rs, landing.rs, me.rs, pictures.rs, devices.rs, accounts.rs, categories.rs, recurring.rs, proposal.rs, apk.rs, sync.rs, rate_limit.rs, rows.rs, validate.rs}
                        src/tasks/  src/sync/  src/audit/  src/push/
                        src/cli/{mod.rs, bootstrap.rs, pair_device.rs, rotate_epoch.rs, healthcheck.rs, invite_partner.rs}
                        tests/  tests/common/{mod.rs, sync_fixtures.rs}   (shared harness, included through #[path] by every test binary)
  crates/kvitt-import/  src/  data/splitwise_categories.csv  tests/fixtures/
android/
  settings.gradle.kts  build.gradle.kts  gradle.properties  gradle/{libs.versions.toml, gradle-daemon-jvm.properties, wrapper/}
  build-logic/          (convention plugins, architecture check)
  core/model/  core/design/  core/fakes/  domain/  data/  adapter/fcm/
  feature/{onboarding, home, account, expense, settle, analytics, settings}/
  app/
infra/
  build-images.sh  publish-apk.sh  README.md  RESTORE.md  .shellcheckrc
  pi/{compose.yaml, Caddyfile, .env.example, .gitignore, install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh, lib.sh}  pi/.env, pi/secrets/, pi/out/ (git-ignored)
  dev/{compose.yaml, .env.example, lib.sh, build.sh, up.sh, logs.sh, backup.sh, copy-google-services.sh}  dev/data/, dev/backups/ (git-ignored)
  cert-sync/{Dockerfile, sync.sh, entrypoint.sh}  backup/{Dockerfile, backup.sh, healthcheck.sh}  secrets/ (git-ignored)

Ownership (an agent edits only paths its milestone owns; anything else needs the orchestrator):

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/; since the server overhaul (8cb42f8) every test binary includes the shared harness tests/common/mod.rs and tests/common/sync_fixtures.rs through #[path], replacing tests/core_api/common.rs and tests/sync_api/m4.rs) 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 (fix pass M6b, landed b24c8b5) M7x may not edit; requests go through the orchestrator; later design plans
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 M7g (feature cleanups), later feature plans
android/feature/home/**, android/feature/account/** M7b M7g (feature cleanups), later feature plans
android/feature/expense/** M7c M7g (feature cleanups), later feature plans
android/feature/settle/** M7d M7g (feature cleanups), later feature plans
android/feature/analytics/** M7e M7g (feature cleanups), later feature plans
android/feature/settings/** M7f M7g (feature cleanups), later feature plans
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
M12 (D-054): contracts/category-keywords.csv, contracts/schema/migrations/**, contracts/test-vectors/suggest_category.json and the other contracts/** edits of §11.2 row M12a, tools/contracts-check.py, android/domain/** (suggestCategory, UnicodeText) M12a (contract-change plan, landed aa765bb) contract-change plans only
M12 (D-054): contracts/openapi.yaml (DefaultCategoryKey) and the category examples M12a held back; kvitt-server migrations/0002_categories_v2.sql, src/db/mod.rs (SCHEMA_VERSION), src/api/validate.rs (token lists), src/wire/category.rs and src/wire/mod.rs (key enum), their tests; kvitt-import data/splitwise_categories.csv, src/categories.rs and its tests M12b (contract-change plan, landed 6eaccec) the usual owners
M12 (D-054): android/core/model/** (enums, icons, ExpenseRepository), android/data/** (UnicodeText binding, suggestCategory, the build-time copy of the keyword table, D-055), android/core/fakes/** (defaults, fake suggestCategory) M12c (contract-change plan, landed b09f322) the usual owners
D-059: android/core/model/** (ExpenseEditorOptions, CategoryRepository), android/data/** (frequentCategoryIds, deletable), android/core/fakes/** (both, outside the fakes/<feature>/ folders) M12d (contract-change plan, landed 557af7e) the usual owners; M7x keep their fakes/<feature>/ folders
docs/validation/<file> the milestone that names the file

Module shells (D-004): a shell file or module created by M2a or M2b in a path owned by a later milestone holds only what makes the build and the 501 stubs work; the later milestone owns it from the moment the shell lands and writes the body.

Strings: each feature module has its own src/main/res/values/strings.xml and values-sv/strings.xml with keys prefixed by the feature name; shared strings (the notification copy included) live in :core:design; :app holds only the app name; lint MissingTranslation is an error in every module (§6.2, D-017). Keys come from docs/ux/strings.csv (§13.4).

14.8 Contract files and the change rule

Contract files (exhaustive): contracts/openapi.yaml, contracts/schema/server-v1.sql, the locked migration texts contracts/schema/migrations/*.sql (0002 since D-054, §14.1.1; 0003 since D-090, §14.1.2), contracts/category-keywords.csv, contracts/test-vectors/*.json, contracts/examples/**, the Room exported schema android/data/schemas/** (version 1 exists since M2b, D-012), android/core/model/** (the UI contract and domain types), the sync protocol (§14.4) and the push payload (§14.6).

contracts/category-keywords.csv (D-049) is the keyword table of §9.11 CS-6: UTF-8 without BOM, \n line ends, header keyword,match,default_key,source, no quoting. keyword is non-empty and already normalised (CS-4 applied to it gives it back), unique in the file; match is token, prefix or phrase; a token or prefix keyword has no space and a phrase keyword has at least one; default_key is a key of AC-7.2 other than other; source is data (learned from the household's export) or curated, documentation only. Rows are sorted by default_key, then keyword, in code-point order. The phone bundles it through a build-time copy (D-055: :data:copyCategoryKeywords and :core:fakes:copyCategoryKeywords, no committed copy, a missing file fails the build, a :data test asserts byte identity and these rules, §14.2.4); M12a seeds the file from docs/ux/category-keywords.csv (148 entries). Changing an entry is a contract change like any other.

Representational edits to contracts/openapi.yaml (key order, $ref naming, description wording) do not change meaning and need no spec bump or decision entry; they are listed in the report of the plan that made them (D-016). The OpenAPI equality test compares parsed values, not text.

The equality test (server/crates/kvitt-server/tests/openapi_contract.rs, D-005) parses both documents and compares them after exactly these normalisations, every one representational; anything else that differs fails the test:

  1. Key order is ignored (objects compared as maps).
  2. Every local $ref is inlined, discriminator mapping values included, so component naming and placement do not matter.
  3. description and example/examples keywords are dropped (properties named description are kept; summary and operationId are compared).
  4. A nullable type array type: [T, "null"] equals oneOf with {type: "null"}; nested oneOfs are flattened and compared as sets.
  5. const: v equals a single-value enum: [v]; enum values and required lists are compared as sets.
  6. required: false and deprecated: false are dropped; a path parameter is always required.
  7. Parameters are keyed by (in, name).
  8. Each operation is compared on its effective security (its own security, else the document's).

The document-wide rules of §14.3.1 and §14.3.2 live in one place on the generated side, the CommonRules modifier in server/crates/kvitt-server/src/openapi.rs, never in a handler annotation: the common request headers and the X-Kvitt-Server-Version response header on every operation, the always-possible error responses of §14.3.2 (authenticated and unauthenticated sets), the document-level device security, and response headers marked required.

Rule: a change to any other contract file, or a semantic change to contracts/openapi.yaml or to §14 of this spec, requires, landed together by the orchestrator: a spec version bump (in the header and the change log), a docs/decisions.md entry, and the matching implementation changes on both sides, or a plan that schedules them. A schema change also bumps the schema version (meta.schema_version and a new migration on the server, whose locked text is the contract file contracts/schema/migrations/<NNNN>_<name>.sql, §14.1.1; there is no server-v2.sql, and server-v1.sql stays the text of 0001; a new Room version and migration on the phone). A sync protocol change bumps X-Kvitt-Protocol; a push payload change bumps v. Numbers come from the orchestrator's brief. Implementation agents never edit SPEC.md or docs/decisions.md: they put the proposed spec text and decision entry in their report, and the orchestrator has a docs agent record them (CLAUDE.md › Orchestration).

Compatibility (v0.13, M12e). The phone's DTOs give new wire fields no default, by design, so a phone running the M12e APK against a pre-M12e server (schema 2) cannot pair (E-3 lacks invite_kind) or list devices (Device lacks app_version_name). The server, the development stack included, is rebuilt and redeployed (migration 0003 runs at start) before the new APK is installed. Phones running an older APK work against the new server: they ignore the unknown fields and the server ignores a missing X-Kvitt-App-Version-Name.


15. Change log

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 (06f48ef) and M2b (c277ee3) 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 (7869ec4) and M5 (8f0e156) 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 (15056dc) 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 (50339ae) 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).
0.8 2026-10-18 Decisions from the owner's UX session (docs/ux/proposed-decisions.md UX-1 to UX-8 on branch UX-sprint). D-047: direction 3 "Kalas" with the muted palette, typeface per D-020 (§9.7, §13.4). D-048: navigation stays at three destinations, an "Aktivitet" tab not taken for v1; §13.2 binding. D-049: category suggestion from the description, new §9.11 (CS-1 to CS-7: new expenses only, manual choice wins, history most often then first token, keyword table, AC-8.6 default, no marker), AC-8.6, ExpenseRepository.suggestCategory (§14.2.4), Kotlin-only suggestCategory in :domain with the UnicodeText interface bound by :data and suggest_category.json as landed by M12a (aa765bb) (§14.5.5, §14.5.6), contract file contracts/category-keywords.csv (§14.8). D-050: S-1 display-name field on a first pairing (AC-2.4, §13.3). D-051: §9.7 hex values become the muted Kalas values from U2, token names fixed, slate and amber TODO(U2). D-052: "Utgiften sparades" without undo (S-7), archived account hides the FAB (S-5), per-account settle-up keeps the swap and an editable amount (S-9), avatar tokens avatar-1 and avatar-2 (AC-4.5), Swedish effect copy (AC-8.10). D-053: finer Splitwise mapping (§10.8). D-054: fourteen default categories (housing, insurance, children, fees added; AC-7.2), icons key, shield, account_balance (33), colours slate, amber (14), server schema 2 through migration 0002 rebuilding categories (§14.1.1, locked as contracts/schema/migrations/0002_categories_v2.sql, copied from M12a's file; no server-v2.sql, §14.8), importer schema check 2 (§10.8), enums in §14.2.4 and §14.3.3, E-1 schema_version 2; Room schema unchanged. New milestone group M12 "Category contract v2" (M12a contracts, then M12b server and M12c Android in parallel; M6 and M7c wait for M12c) (§11).
0.9 2026-10-18 Outcomes of M12b (6eaccec) and M12c (b09f322), U2 (landed 6da6b1b) and UX-9. D-055: the keyword table is copied from contracts/category-keywords.csv at build time by :data:copyCategoryKeywords and :core:fakes:copyCategoryKeywords (AGP generated resource directory), a missing file fails the build, packaged as se/hagstedt/kvitt/data/category-keywords.csv (and the fakes path in debug), no committed copy, a :data test asserts byte identity and the §14.8 rules; supersedes the android/data/src/main/resources copy (§11.2 M12c, §14.2.4, §14.7, §14.8). D-056: pure parseCategoryKeywords(csv) in :domain, shared by :data and :core:fakes (§14.5.6). D-057: DefaultCategoryKey declared in AC-7.2 order; the Rust wire enum, the OpenAPI enum and the 0002 CHECK list the ten v1 keys then the four new ones; wire values unaffected (§14.2.4). D-058: test harnesses seed users after the fourteen category seeds (server_version 15 and 16); E-7-me/response.json shows 15 and 16 and its test compares them again (§14.3.5). D-051 fulfilled: §9.7 hex values from docs/ux/design-spec.md §2.3; design-spec.md is the binding design document and strings.csv (558 rows, D-017 prefixes, plurals as key:one/key:other) the source of string resources (§13.4). D-059: UX-9 accepted as a contract change: ExpenseEditorOptions.frequentCategoryIds and CategoryRepository.deletable(id) with their readings and fallbacks (§14.2.4), new milestone M12d (§11, §14.7); M7a to M7f depend on M6 and M12d. Clarifications without a decision id: suggestCategory history is every non-deleted expense (pending included, all accounts, both users), active categories neither archived nor deleted, fallback last-used in the account if active else other, null account gives other; when other is archived and no active last-used exists the result is null and the editor shows no category until the user picks one (AC-8.6, §9.11 CS-5, §14.2.4); stored expense_date values are valid ISO dates (§9.6), so parsing cannot fail. §11.2: M12a, M12b, M12c, U1, U2 landed, M6 started 2026-10-18; dry run of 2026-10-18 (Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled) (§11.2, §11.3). §10: development stack migrated to schema 2 on 2026-10-18 after a backup. PLAN-M12b: bundled SQLite is 3.51.x (3.51.3), not 3.50.
0.10 2026-10-18 Outcomes of M12d (557af7e) and M6 (7730a31) and two owner-approved string decisions. D-060: frequentCategoryIds holds only categories used in the account (possibly fewer than five or empty); filling to five and placing the selected category fifth stays in the editor; not a contract change (§14.2.4). D-061: a key for every active account (empty list when unused), none for archived accounts; ties by count, sort_order, then category id (§14.2.4). D-062: deletable is false for an unknown or deleted id; archived categories follow AC-7.6; fake delete and deletable share one rule (§14.2.4). D-063: charts on Compose Canvas in :core:design, not Vico (3.3.1 cannot meet design-spec §3.10); Vico stays in the catalog unused, removable later (§6.2). D-064: Material 3 Expressive against material3 1.4.0: ShortNavigationBar used; MaterialShapes, ButtonGroup, LoadingIndicator, LinearWavyProgressIndicator, medium extended FAB, button sizes, segmented list items, expressive slider, MotionScheme.expressive() and the increased shapes take fallbacks; fulfils design-spec §6 open point 5 (§6.2). D-065: sv and en month and weekday tables in DateFormatter; chart labels without dots; file sizes in SI megabytes with one decimal (§9.8). D-066: golden layout per component sheet × light/dark × sv/en, plus 200 % for long states, switched through a configuration context; M7a to M7f follow it (§6.2). D-067: English default name of restaurants is "Dining" (AC-7.2, strings.csv, :core:design). D-068: shared ledger_settlement_label ("Payment" / "Betalning") for the settlement row's type label; settlement row titles may wrap to 2 lines (3 at 200 %); an empty chart range shows only the 0 baseline (§13.3, §13.4). Without a decision id: :core:design described as built (§6.2); SyncStatusPill takes a failed flag until SyncSummary gets a "last attempt failed" field (§13.3, M8 request); M6's M8 contract requests LocalAvatarPictures/AvatarPictures (authenticated E-11 request from user id and pictureVersion), ProvideHousehold(me, partner), LocalKvittClock; design-spec §2.7 One UI font check at the M8 install (§11.3). §11.2: M12d and M6 landed, M7a to M7f started 2026-10-18.
0.11 2026-10-18 Outcomes of the M7 wave (M7a 1ea752c, M7e ea7d494, M7d 7380e50, M7b 0e1d99d, M7c 0b140aa, M7f c4ab35f) and the design-system fix pass M6b (b24c8b5); owner approved in advance. D-069: S-11 total caption " · Hushållet" / " · N veckor", label "Totalt · Min del" for my share, whole-month ranges for month presets and days otherwise, legend and stacking by sortOrder and AccountRepository.all() order, AnalyticsState.groups order unspecified, loading = lastSuccessAt == null with skeleton cards, offline looks like loaded (§13.3, §14.2.4). D-070: S-12 week title per strings.csv ("Vardag, vecka 41"), whole-range title ", ", no error state on S-11/S-12, no loading state on S-12 (§13.3). D-071: top-level screens S-3, S-4, S-11, S-13 render their own KvittNavigationBar with onNavigate, no second bar in :app; navigation-argument keys are feature constants read through SavedStateHandle; result snackbars shown by the screen returned to, account-archived snackbar with undo (§13.2). D-072: a new template's day of month defaults to today (AC-9.8, S-19). D-073: S-22 "Sök efter uppdatering" reports "Du har den senaste versionen" when UpdateState stays None; network failure indistinguishable, accepted for v1 (new AC-16.6, §14.2.4, §11.3). D-074: FieldError.field values are the model property names; an edited settlement keeps its saved rows, "Föreslå igen" re-proposes; S-9's untouched-accounts line derives from Home balances (AC-11.6, §13.3, §14.2.4). D-075: the D-060 fill is :core:design's quickRowCategories, replacing design-spec §3.7's interim rule (§14.2.4). D-076: category labels up to two centred lines, long custom names end in "…" (§13.3 S-7). D-077: day ranges in captions and summaries written out with the year, chip and S-12 title keep the short form (§9.8). D-078: stored amounts open for editing with both decimals, ratio percents trimmed (§9.6). D-079: shared keys common_ok, common_undo, editor_split_full_on_you and the M7b–M7f module keys recorded in strings.csv (578 rows, header included) (§13.4). D-080: "Synka nu" on S-8, S-10 and S-16 is online only; discard dialogs of S-6, S-7, S-9, S-19 have no body text (§9.9, §13.3). Without a decision id: §6.2 :app Hilt wiring before M8 (release binds :data since 6453932, the debug fakes module a41f240 is deleted by M8) and feature screenshot tests full screen at 411 × 891 dp xhdpi; §13.3 S-1 generic help line, S-2 blank-name error, S-3 error state, S-5 non-sticky month headers and "60/40" ratios (common_ratio), S-16 pending rows use the shared ledger rows, "Senare" hides the update banner for the ViewModel's lifetime; §11.3 contract requests for a later contract pass (M12e). §11: M7a–M7f and M6b landed, M8 and M7g started 2026-10-18 (M8 holds D-085 to D-089, renumbered to D-081 onwards on landing); §14.7 owners after landing.
0.12 2026-10-18 Outcome of M8 app integration (cb832a7 and 3d9eea8, gate green: BUILD SUCCESSFUL, 710 actionable tasks). D-081: both variants bind :data through di/AppDataModule.kt in src/main (includes DataBindingsModule); DebugFakesModule deleted; fakes only in tests and feature previews; :app tests on a plain Application; revises D-041 (§6.2, §11.3). D-082: a network security config cannot express IP ranges, so the debug config allows cleartext everywhere and SessionRepositoryImpl.normaliseServerUrl enforces the AC-2.8 limit; release config in src/main, debug override in src/debug, pinned-root config in src/releasePinnedRoot/res behind kvitt.tls.pinnedRoot; the debug InsecureBaseConfiguration lint warning is intended (§8.5, §10.5.3, AC-2.8). D-083: FCM registration by Firebase Installation ID (firebase-messaging 26.0.0, BoM 35.0.2, google-services 4.5.0): register() while paired and at each start, onRegistered(fid) sends fcm_token through E-17, unregister() on revoke, firebase_messaging_installation_id_enabled meta-data; no server change, fid field as a follow-up request (AC-13.7, §6.2, §14.6). D-084: an invite link opened while paired is ignored, never re-pairs (AC-2.6). D-085: a tap on the generic notification, a deletion or sync_batch opens Home; unknown type or v ≠ 1 only syncs; channel activity, notification id = hash of entity_id, sync_batch id 1; push handled by an expedited WorkManager job calling syncForPush (20 s) then building the text from Room (AC-13.6, §14.6). Without a decision id: the session gate (S-1, S-2, S-18; S-3 while partner == null) in §13.2; M8's :app wiring facts (LocalKvittHousehold, LocalKvittClock, LocalAvatarPictures, permission after the first sync, sync and update triggers, unsigned release without keys, exported components) in §11.2, §11.3, §8.8, §10.6, AC-2.9; M8's contract requests on the §11.3 M12e list (PushEvent to :core:model, S-22 licences, detail-screen snackbar hosts, S-7/S-8 snackbar strings, launcher icon, fid); new OQ-5 (licences screen, default not in v1). §11: M8 and M7g (3dda414) landed; M12e started 2026-10-18. The end-to-end pairing, offline and push test and the design-spec §2.7 typeface check on the phone are still to run.
0.13 2026-10-18 Outcome of the M12e contract pass (8c60715 contract pass, b7b7483 launcher icon; gates green: Android BUILD SUCCESSFUL, 710 actionable tasks, 906 tests; server 253 tests with the OpenAPI equality, vector and migration byte-identity tests). D-086: E-3 returns invite_kind; SessionRepository.pair returns OnlineResult<Pairing(user, inviteKind)>, an unknown kind reads as device; S-2 follows only a partner pairing; FakeScenario.pairingInviteKind (AC-2.4, AC-2.10, §13.2, §13.3 S-2, §14.2.4, E-3). D-087: E-12 link <public_url>/i/<code>?from=<admin name>; E-4 takes optional from and passes it only into kvitt://invite?server&code&from, never shown, an invalid value dropped; InviteLink, AppLink.Invite, PairingScreen(initialAdminName), PairingViewModel.prefill carry it; S-1 shows onboarding_code_help with the name; links without a name still parse (AC-2.5, AC-2.6, §13.3 S-1, E-4, E-12). D-088: SyncSummary.lastAttemptFailed; SyncStatusPill, syncPillVariant, syncLineText drop failed; S-16 failed = online and lastAttemptFailed (§13.3 S-4, S-16, §14.2.4, §11.3). D-089: Rejection.actor from the stored current.updated_by_user_id, partner as fallback; no protocol change (§14.2.4, §14.4.6). D-090: optional request header X-Kvitt-App-Version-Name on every operation, stored at E-3 and the hourly last-seen update, blank or over 32 characters ignored; migration 0003 adds devices.app_version_name, server schema 3 (new §14.1.2, contract copy with byte-identity test, v2→v3 migration test); app_version_name on the Device wire object; Device.appVersionName; S-20 shows "Kvitt 1.1"; importer expects schema 3; Room unchanged at 1 (§8.2, §10, §10.8, §14.1, §14.3.1, §14.3.3, E-1, E-3, AC-3.1, §13.3 S-20). D-091: RecurringRepository.create(NewRecurringTemplate), the server mints the id (§13.3 S-19, §14.2.4, E-28). D-092: UpdateRepository.check(): UpdateCheck (UpToDate, Available, Failed), 404 is up to date, debug reports UpToDate; S-22 up-to-date or could-not-reach-server; revises D-073 (AC-16.6, §13.3 S-22, §14.2.4). D-093: SettleState.untouched with nets excluding the edited settlement, empty per account; revises D-074's S-9 reading (§13.3 S-9, §14.2.4). D-094: the D-027 re-stamp as implemented: at the start of a cycle with clock_skew set, pending rows stamped later than now are re-stamped to now (§14.4.5). D-095: PushEvent in :core:model; six screens host the shared snackbar host, no :app overlay host; snackbar resource ids from S-7 and S-8; adaptive vector launcher icon (#9C5238, receipt_long at 48 dp, monochrome layer); revises D-071 (§6.2, §13.2, §13.3 S-7, S-8, §14.2.4, §14.6). Without a decision id: the 128 px picture size left unchanged by assumption (owner question open); the M12e list in §11.3 replaced by its outcome and a "Remaining contract requests" list (OQ-5 licences, fid); goldens re-recorded (S-20 devices; new s1_pairing_link_admin, s22_about_check_failed); compatibility note: the M12e APK needs a schema-3 server, so the server is redeployed first (§14.8); dev stack at schema 3, up.sh --down then up.sh on Podman (§10). §11: M12e landed (§11.1, §11.2).
0.14 2026-10-18 Follow-ups after M12e (a312758, f56dcbe, 3e6c337). D-096: the server sends the stored fcm_token (the Firebase Installation ID of D-083) in FCM HTTP v1 message.fid, no longer in the deprecated message.token; no database or wire change, the column and the E-17 field keep the name fcm_token; contracts/examples/push/*.json use fid; revises D-083's "no server change" (§14.6, AC-13.7, §11.3). Without a decision id: the Pi image's C stage and CA roots and an OAuth plus FCM call from the arm64 image confirmed on the Mac (docs/spikes/pi-image-arm64.md; 63.8 MB, cold build 48 s), running on the Pi stays M2c-pi's (§10.3); Vico removed from the version catalog (a312758), D-063 pointer (§6.2); §11.3 remaining contract requests: only OQ-5.
0.15 2026-10-18 The first phone test, M8b (328c466) and owner answers. D-097: chart bars top corners 4 dp, bottom 2 dp, replacing 10/4 (§6.2, §13.4, design-spec §2.9, §3.10). D-100: no SSH from the Mac to the Pis; M2c-pi delivers the infra/pi/ bundle the owner copies to the Kvitt Pi and runs, OQ-3 values as .env placeholders, M2c-pi last after the app is proven, no agent allowances on the Pis, Home Assistant, Gitea or Drive; revises D-023, keeps D-001 (§10 intro, §10.1, §10.10, §11.1, §11.2, §11.3, §12). D-101: the release keystore kvitt-release.jks at the repository root, git-ignored by *.jks, absolute path in android/local.properties, copy and password in the owner's password manager; revises D-024 (§10.6, §11.2 M10, §11.3). Without a decision id: the M8 end-to-end test passed on the owner's S26 against the development server (pairing, scripted partner via a second admin device, offline pending and sync, fcm_token and app_version_name registered, push by fid delivered and tapped); M8b fixes (invite-code VisualTransformation, quick-row badge above the ripple clip, KvittTopAppBar status-bar inset, 221 goldens) and FcmMessageBody.fid (6eb3f85) (§11.2 M8, §11.3); OQ-5 answered: licences button hidden in v1 (M8c); the 128 px picture size not needed (§14.2.4); the "boende" keyword keeps suggesting travel and the account page split bar stays as designed (no change).
0.16 2026-10-18 M8c (75b549f, a0c25b0, 7c453d8), the R8 release build (c30a49e) and M8d (bacbd18). D-098: goldens in three variants <name>_sv_light, <name>_sv_dark, <name>_en_light plus <name>_sv_light_200, rendered at 1x through w411dp-h<height>dp-mdpi, every committed PNG compressed with oxipng -o 4 --strip safe (1143 PNGs / 131 MB to 865 / 26 MB, mock-ups 36.8 to 26.0 MB); revises D-066 (§6.2, §13.4). D-099: S-22 has no open-source licences row, onOpenLicences and settings_about_licences removed, strings.csv 577 rows; closes OQ-5 (§11.3, §12, §13.3, §13.4, design-spec S-22). D-102: S-7 options as four fixed full-width rows (Konto, Datum, Betalt av, Delning), EditorOptionRows in :core:design; connected buttons 8 dp side padding (§13.3, §13.4, design-spec §3.7, §3.10). Without a decision id: release builds R8-shrunk, optimised and obfuscated, APK about 11 MB (was 43 MB), mapping.txt kept beside the APK; release signing verified (alias kvitt, RSA 4096, v2) (§10.6); §11.1 graph.
0.17 2026-10-18 The M2c-pi bundle (2476667), M8e (a59abeb), M8f (956908a), the chips cleanup (1328afe) and the Dockerfile CARGO_BUILD_JOBS argument (b4d9467). D-103: the infra/pi/ bundle (compose.yaml, was infra/compose.yaml; .env.example, was infra/env.example; Caddyfile; install.sh, deploy.sh <tag> [--skip-backup] with a backup before every deploy and /opt/kvitt/previous-tag, bootstrap.sh, backup-now.sh, install-apk.sh), the APK path infra/publish-apk.sh → infra/pi/out/apk/ → /opt/kvitt/apk/ → install-apk.sh into the data volume, mappings outside the volume, .env keys KVITT_REGISTRY_USER, KVITT_REGISTRY_TOKEN, KVITT_PI_HOST, KVITT_PI_SSH_USER, gate docker compose -f infra/pi/compose.yaml config; fulfils D-100 (§10 intro, §10.2, §10.3, §10.6, §10.7, §10.10, §11.2, §14.7). D-104: images `${KVITT_REGISTRY}/kvitt-server
0.18 2026-10-18 M12f (dc7a296, 5b2ed37, 2bd6b58, c366fbd, a0259ab), the server gap pass (8660b7f), the server overhaul (8103dae to b985ce9), the infra overhaul (2fac1ec to e70fe0d) and infra security fixes (7fb86f5), the AC-coverage audit and the security review. D-109: S-16 shows a rejected change's lost version read-only, Rejection.lost: RejectedVersion? with RejectedVersion(row, accountName, payer, mode, shares, notes, deleted) from rejected_payload, null when undecodable or a user or the category is missing; dialog "Din version", "Du tog bort den här", "Visa nuvarande version"; ledger rows take a null onClick (read-only); four home_sync_lost_* strings (§13.3 S-16, §14.2.4, §14.4.6, design-spec S-16). D-110: AboutState.schemaVersion; S-22 reads /health when it opens online and shows " (schema )" (settings_about_server_version_schema), header fallback offline (§13.3 S-22, §14.2.4, E-1, design-spec S-22). D-111: SettlementRepository.start and recompute return SettleState? (null without a session or partner), save returns Invalid(payer, validation_failed); no throws (§14.2.4, AC-5.1). D-112: a failed E-17 send is retried after each successful sync until accepted, a newer FID replaces it (AC-13.7). D-113: kvitt-server invite-partner (0 ok, 2 household full, 3 no admin) and the push sent info line (§8.7, §10.4); tools/contracts-check.py 134 checks. D-116: notifications VISIBILITY_PRIVATE with the generic public version (new AC-13.9); L-6 kept as built, S-1's server row expanded for a non-default server, the confirm step listed as OQ-6 (AC-2.6, AC-2.8, §12). D-117: Caddy log redaction of /i/<code> and query strings, Content-Security-Policy, X-Content-Type-Options, X-Frame-Options, Referrer-Policy (§8.7, §10.5.2, which now quotes infra/pi/Caddyfile at 7fb86f5); Caddy as uid 10002 with group 1000, read-only, cap_drop ALL plus NET_BIND_SERVICE, tmpfs; /certs 0750 1000:1000, caddy_data 10002, extending D-105 (§8.9, §10.2); the development stack copies only fcm-service-account.json into infra/dev/secrets/ (§10 intro); /opt/kvitt/deployed-digests with a WARNING on a changed digest and a workflow digest step; kvitt_cicd with write:package only (§10.3, §10.10); the plain root key (variant B) removed (§10.5.1); the remote_ip check (§10.10). D-114: pictures at most 16 000 000 pixels and 64 MB decoded, image_invalid (AC-4.4); fcm_token 1 to 512 characters, else invalid_request (§9.6, E-17). D-115: a token is accepted only after a 2xx, an unaccepted token counts against its IP, a 401 withdraws acceptance, entries pruned when their window expires; revises D-032 (§8.6). Without a decision id: the server overhaul (8103dae to b985ce9: shared row reads and inserts, invite creation and CLI helpers, one test harness tests/common/, clippy pedantic wins, synchronous tasks::start, not_implemented no longer produced; 268 server tests; all crates at their latest versions) (§6.3, §14.3.2, §14.7). Without a decision id: install.sh keeps the deployed KVITT_TAG, --provenance=false, the shared tools/gradle-lock.sh, RCLONE_REMOTE dropped from the development env (§10 intro, §10.3, §11.3); strings.csv 577 to 562 rows (§13.4); localeFilters en, sv (AC-19.1); work-testing and the screenshot harness fix (§6.2); release APK 10.0 MB (§10.6, §10.10); M12f row in §11.2 (941 tests); §11.4 validation reports (docs/validation/ac-coverage.md, docs/validation/security-review.md); the history rewrite and first push to origin (18c527f): every commit hash cited in this file is the rewritten one; 7c453d8 (the oxipng pass) no longer exists as a commit, its PNGs are in 18c527f.
0.19 2026-10-18 The Android overhaul (e4e6e5d to c582363). D-118: shared test support in :core:design's test fixtures (se.hagstedt.kvitt.design.testing: Variant, TestClock, TestEnvironment, ScreenSnapshots with Root/Screen/ScreenWhenIdle, MainDispatcherRule, withMain) through testImplementation(testFixtures(project(":core:design"))), not a module, the architecture check rejects test fixtures outside test configurations; the convention plugin kvitt.android.screenshots; format/Failures.kt, format/Limits.kt and savedSplitLabel in :core:design; KvittMotion only the seesaw spring, KvittIcon.Description and unused dimens and shapes removed; four catalog entries removed; one CopyCategoryKeywords task (§6.2 :core:design row and Screenshot tests, §11.1, §11.3; design-spec §2.11 and the icon table). Without a decision id: overhaul outcomes in §11.3 (Android c582363, server b985ce9, infra e70fe0d); 941 tests, no golden changed, release APK 10.46 MB; pushed to origin at c582363; infra/README.md APK size about 10 MB; pre-rewrite hashes in PLAN-*.md replaced by the rewritten ones.