Jotry docs
Browse the project notes. Use the sidebar to move between pages.
- Project plan: the source of truth (decisions, architecture, scope).
- Spikes: proposals and future ideas, not agreed behavior.
- Tests: coverage catalog by feature.
Pending items
What’s still open, in one place. Each section points to the doc that owns the details. Delete an item here when it’s done, and move it to the roadmap if it grows. Last reviewed 2026-10-12 (the devices list, Remove a device, device names, the delete-versus-edit prompt, sync after edits, background sync).
Contents: Next up · To verify · Release 1 and beta · Cloud sync epics · Other future work · Open decisions · Spikes waiting for a decision · Test gaps · Housekeeping
Next up
- Open the Apple, Google Play and Microsoft developer accounts (epic 8). They have lead time and block nothing else.
- Finish Phase 2 (beta readiness, reviews, manual accessibility QA).
- Epic 7, the apps’ sync screens. Turning sync on, joining by QR code and a first status screen are built on both apps and work on an Android phone against the test server (2026-10-11); the devices list and Remove are built (2026-10-12), and so are the delete-versus-edit prompt, sync after edits and background sync with its notification. Sync works between an iPhone and the Android phone (2026-10-12). Next: try today’s screens on the phones, then recovery. Epic 6 is built except merging edits that don’t overlap (optional). Epics 3 and 4 are done; epic 5 waits on the Apple account for its verifier.
- Run the
serversuite of the Tests workflow once on GitHub (epic 1).
To verify
-
Root key rotation and turning sync on, against the test server. The test server runs the rotation build since 2026-10-11 (migration
V7__root_key_rotation.sql; groups made before it cannot be rotated, which only prototype groups are). Sync between a phone and the desktop app works on the test server (reported by the owner, 2026-10-11; which direction and what was checked was not written down).LiveSyncServerTestpassed again with a new promo code (2026-10-11). On an Android phone (moto g play 2023, Android 13, debug build; 2026-10-11): a made-up promo code is refused and the app goes back with the same 24 words; a wrong recovery word is refused; the real code turns sync on. Two throwaway JVM devices on the Mac joined by the phone scanning their QR codes. Checked: notes, an edit and a delete go both ways; two edits of one note make a “(conflict)” copy on both; an edit on the phone against a delete on the other keeps the note on the phone and holds the delete (D-73), with no prompt (not built). A rotation by hand: removing a device through the API moved the root key from 1 to 2 on the next sync, the phone picked up key 2 and notes went both ways after it. On 2026-10-12 the owner turned sync on on miPhone (iPhone 13 Pro) and the moto and they synced, so turning on and joining by QR code work between two phones and on iOS (reported by the owner; which notes and which build were not written down). Still to do: eyePhone. -
server/Dockerfile.dockerignorere-includes each parent directory on its own line (2026-10-10), because one builder rejected the lone!server/build/install/**with “changes out of order”. The amd64 image builds with it. Confirmdocker compose ... up --buildstill builds the same image with Docker. -
ScreenshotTest.notebookis flaky on CI. It timed out waiting for “Quarterly plan” on the Work notebook screen in runs 37723730099 and 37731691315, then passed in 37786555941 (all suites green, 2026-10-08). It never fails locally. On CI both tests that open that screen took about 10 s while the others took under 1.5 s. When it fails again, the report prints what was on screen. -
NoteEditingUiTest.titleIsTheFirstLineUntilRenamedis flaky on the API 37 emulator. It timed out waiting for “Title: Trip plans” once (2026-10-12) and passed on the next two runs of its class. -
The iOS Settings snapshot no longer matches its reference.
ScreenSnapshotTests.testSettingsfails on the iPhone 17 Pro simulator (iOS 26.5) with or without the 2026-10-12 background sync changes, which don’t touch Settings. Look at the diff image and re-record if the change is right. -
The first key typed into a new note on Android can be lost. On a moto g play 2023 (2026-10-11), text typed by
adb shell input textwithin about half a second of tapping New note lost its first character (“Moto” became “Oto”); after a 3-second wait nothing was lost. Check whether a person typing quickly hits it, and whether the editor takes focus late. -
Generated titles on an Android device with Gemini Nano (D-28, D-30) have never been tried.
Release 1 and beta (Phase 2)
Phase 1 features are built. What’s left from the roadmap is mostly Phase 2:
- Beta readiness: a beta release checklist, published known limitations and recovery steps, opt-in diagnostics that never collect note contents, and a support procedure for data recovery.
- Reviews: migration, storage, performance, accessibility, localization-readiness and privacy.
- Manual QA for accessibility and keyboard flows. Phase 1 can’t exit without it.
- Performance numbers: startup, editor latency, search, and history size.
- Minimum versions (D-59: iOS 17, Android API 28). The build settings
are changed and the Android API 24 workarounds are removed (variable-font
fallback in
EditorSpans.kt, theSDK_INTcheck inTitleGenerator.kt, the ML Kit minSdk override). Both apps build again (2026-10-10): the iOS build needed.toolbar(removing: .title)gated to iOS 18, so on iOS 17 the notes list shows the notebook name in the bar too. Not yet tested: Android unit and screenshot tests, then a run on an API 28 emulator; iOS tests and screenshot tests on an iOS 17 simulator, and anything else newer than 17. Write the support policy. - Importing into a chosen notebook: core supports
targetNotebookId, but both apps always pass null.
Cloud sync (Phase 4)
Account-free sync groups (D-45), paid through the stores (D-52 to D-58). The server keeps its logic behind interfaces so the web framework can be swapped (D-60). Design: sync-system-design.md. Server options and the older work order: the spike section 10. Epics are in order; later ones depend on earlier ones unless noted. Hosting (provider, VPS, managed Postgres) and performance measurement are deliberately last: they are needed only when going live.
Epic 1: Local development environment and server structure
-
server/serviceholds the framework-free logic andserver/srcis the Ktor adapter (D-60);HealthServiceand/healthare the first example, with tests. -
server/Dockerfile,server/compose.dev.yaml(Postgres, and the server under a profile),server/compose.test.yaml(a test server with its own Postgres) andserver/README.md. - The build and all 16 server tests pass, and both compose files run for
real: the server migrates at start,
/healthanswers 200 with the database and 503 within 3 s when Postgres is stopped, and recovers when it is back. Idle memory was about 160 MB for the server container (2026-10-09). - Libraries chosen (D-61): Koin, Exposed (DSL), Flyway, HikariCP. The
server/postgresmodule readsDATABASE_*, opens the pool, applies the migrations at start (an emptyV1__baseline.sqlso far) and gives/healthits database probe. Tests run on a real Postgres, a fresh database per test. - A
serversuite in the Tests workflow, run by hand like the others (never on push): the server and:syncApiJVM tests and:server:check, thencompose.dev.yamlwith a development build of the server in a container, a/healthcheck and a sync route check. Written; not yet run on GitHub, and the compose step has not run locally (no Docker Compose on the development Mac).
Epic 2: Server foundation (K1, K3)
- S3: the shared crypto envelope with test vectors. Done in
:cryptoon libsodium (D-47), with a new note key per rotation and formats version 2 (D-64). The tests pass on JVM, the iOS simulator and the API 37 emulator (25 tests, 2026-10-10, after D-64). - The record model (D-65) and the wire format (D-63) are decided.
-
:syncApi, the shared Kotlin Multiplatform module (JVM, Android, iOS) with the sync API’s CBOR request and response classes and theSyncApi.cborsettings (integer labels, byte strings, unknown keys ignored, definite lengths). The route list is inSyncApi.kt. Its 5 tests pass on the JVM, the iOS simulator and the API 37 emulator (2026-10-10), and it compiles for iOS devices. - Groups and entitlements:
SyncGroupwith the recovery fields (D-55),Entitlement, theGroupStoreport, andEntitlementPolicy(entitled while any is active, read-only when lapsed, expired a year later; D-52, D-56) inserver/service; the in-memory store and the sharedGroupStoreContractin its test fixtures;PostgresGroupStore(Exposed DSL) andV2__groups_and_entitlements.sql. When a group lapsed is worked out from its entitlements, not stored. The clock isjava.time.Clock. - K3: records (D-65).
RecordStoreport andRecordService(the conditional put onseq, the change feed in pages of at most 500, and the write rules: kind and note never change, revisions written once, deletes as tombstones, push only when entitled, pull until expired) inserver/service; the in-memory store andRecordStoreContractin its test fixtures;PostgresRecordStore(Exposed DSL) andV3__records.sqlwith thegroup_counterrow taken under a lock until commit. Groups are created from a verified purchase since epic 5 (D-70). - K1: the Ktor routes for
:syncApi(create a prototype group, put, changes) inserver/src/.../SyncRoutes.kt, wired with Koin (syncModule). Since epic 4 the record routes need a device token; since epic 5 group creation needs a verified purchase, and theSYNC_PROTOTYPEswitch of D-62 and D-68 is gone (D-70). Tested in Ktor’s test host and on Postgres, and called over the local network from another process with a hand-made CBOR client (2026-10-10, before auth). - Try the routes from a real phone: the Android app syncs with the test server (2026-10-11).
- Devices: a record’s
author_device_idis the authenticated device (epic 4). - Optional: the same two endpoints as an http4k adapter, to prove the logic is framework-independent.
Epic 3: Crypto completion (parallel with epic 2)
- Ed25519 signing keys in
:crypto(SigningKey), with RFC 8032 tests. - Recovery key (D-66): BIP-39 English, 24 words with checksum; the lookup id, X25519 encryption key (D-75, was a wrapping key) and recovery signing key by HKDF-SHA-256 (D-55); the 110-byte recovery copy of the root key, sealed to the encryption key’s public key so any member can replace it; known-answer tests. Pass on JVM, the iOS simulator and an API 28 emulator (2026-10-11).
- A long-term X25519
DeviceEncryptionKeyper device (D-74), kept in theKeyVaultbeside the signing key; the vault now keeps every root key a device has held (D-75). - Store keys in the Android Keystore (
AndroidSecretStore) and the iOS Keychain (IosSecretStore), behindSecretStoreandKeyVault. The Keychain tests pass on a booted simulator; the Keystore tests on API 37 and API 28. - Run the
:cryptotests on Android API 28 (minSdk) and 37: 48 tests pass on both, as do the 5:syncApitests on API 28 (2026-10-10). The API 28 emulator is theApi28_PhoneAVD. On a real phone (moto g play 2023, Android 13) all 56:cryptotests and the 43 Android app tests pass (2026-10-11). - The desktop key store (D-51: macOS Keychain, Windows DPAPI, Linux Secret Service, passphrase fallback). The macOS Keychain is built (D-78); the rest is not. Desktop (D-76) is outside Release 1.
- The optional keychain backup of the device key (D-57), with the turn-on-sync screen (epic 7).
Epic 4: Group, device and recovery auth (K2)
- Group creation registers the first device (signed with its key), the
challenge-and-token flow (opaque hashed tokens, D-67), device list and
remove (
AuthService,PostgresAuthStore,V4__devices_and_auth.sql). - QR enrollment endpoints; an enrollment expires after 10 minutes.
- The recovery endpoints (challenge, enroll) from section 5 of the design,
tested with the real
RecoveryKeyandDeviceEnrollmentfrom:crypto. - Tests: a removed device is refused at once, replayed and cross-purpose
challenges are refused, no group-data endpoint accepts an unauthenticated
request, a token for another group is refused, and the server’s statements
match
:syncApi’s byte for byte. - Design the reverse QR flow for an existing device with no camera.
- Rate limits on the unauthenticated endpoints (D-69): token buckets per
client address, recovery also per group, 429 with
Retry-After.TRUST_FORWARDED_FORfor use behind a reverse proxy. - A maintenance job (every 5 minutes,
MAINTENANCE_INTERVAL_SECONDS) that deletes expired challenges, tokens and enrollments and drops idle rate-limit buckets (D-69). -
TRUST_FORWARDED_FOR=trueon the test server, behind Caddy (2026-10-11).compose.test.yamlpasses it through fromserver/.env(default false). - Rotation (D-64, D-75): removing a device is a plain removal; the next remaining device to sync rotates (epic 6).
Epic 5: Entitlement (K2b)
- The verifier interface (
EntitlementVerifier, one per source) and group creation from a verified purchase whose account token is the group id (GroupCreation, D-54). The development verifier is inserver/dev, which only a development build contains (:server:run, the tests,installDist -Pdevelopment);:server:checkfails if it reaches an ordinary build (D-58, D-70).SYNC_PROTOTYPEis retired. Live-checked 2026-10-10: an ordinary build answers 422unsupported-source, a development build 201. - Entitlement rows, “entitled while any is active” and read-only lapsed
groups (
EntitlementPolicy,GroupAccessService; built in epic 2). - The job that deletes a group a year after it lapsed (D-56): part of the
maintenance job, one
DELETEon Postgres that cascades to everything the group holds. - Add a purchase to an existing group (D-71):
POST /v1/groups/{groupId}/purchasesthrough the same verifiers. A new transaction adds a row; one the group holds is updated in place (expiry only later, a refund keeps its earliest time); another group’s is 409. - Promo and test entitlements (D-72): promo codes stored on the server as
hashes, created with
bin/promo(create,list,revoke), redeemed aspromopurchases on group creation or the purchases route by every build. Verifiers are now told which group a purchase is for. - The apps have no way to enter a promo code yet (with the sync UI).
- After launch, look at the stores’ own offer codes (Apple offer codes, Google Play promo codes) for marketing (D-72).
- Apple verifier against recorded signed transactions; store notification handling for renew, expire and refund.
- Spikes: the Google equivalent of Apple’s account token and server-side purchase verification, and Microsoft Store verification (decide whether Windows is in scope).
Epic 6: Client sync engine (S4, S5)
- Sync tables (schema version 4:
outbox,sync_state,remote_map,sync_note_key,sync_pending,sync_conflict), with triggers that fill the outbox in the same transaction as every change while sync is on.SyncEngineinapp/sharedLogic/.../sync: keys per note and notebook, push in dependency order with the conditional put, paged pull applied a page per transaction, merging by the revision graph, and the delete-versus-edit decision (D-49) held untilresolve. Tested againstFakeSyncServerwith injected faults, including a randomized three-device run (18 tests, sync-engine.md). - Where the delete-versus-edit prompt appears (D-73): on screen during
sync, or a notification that opens it when sync ran in the background. The
engine lists the notes in
decisions(); the screen and notification are epic 7. -
KtorDestinationinapp/sharedLogic/.../sync: CBOR over the Ktor client (OkHttp on Android, Darwin on iOS, CIO on the JVM), a token from the challenge kept in memory and renewed on 401, and 409, 422, 403read-onlyand 410 mapped toPutResultandSyncNotAllowedException(now with a reason); other answers throwSyncServerException. The engine suites run again against the real server started in process, over HTTP, with groups created and devices enrolled by QR as the apps will (36 tests, plus 7 for the destination; 2026-10-11). Android and iOS compile; no test runs there.LiveSyncServerTestpassed against the test server athttps://jotry.home.sixface.in(behind Caddy, an ordinary build, a promo group; 2026-10-11). - Group creation, QR enrollment and recovery for the apps: only the tests
have them (
HttpSyncGroup). They belong with the turn-on-sync and add-device screens (epic 7). -
NotesCore.sync(CloudSync): the engine on the core’s dispatcher and repository,changesbumped when a sync changed notes, the group id and server URL insync_state, the root and signing keys inKeyVault(Android Keystore, iOS Keychain; in memory for in-memory cores), and aKtorDestinationmade on first use.jointakes the keys a create, QR enrollment or recovery produced;leaveforgets them on this device only.:cryptois now anapidependency ofsharedLogic. 4 tests on the JVM (2026-10-11); both apps build. - When the apps sync: after changes (5 s after the last one), at start, on return to the foreground, and in the background every 30 minutes (epic 7).
- Root key rotation (D-64, D-74, D-75). Server: migration V7, the current
root key id and last rotation date, per-device encryption keys and the key
each device was last given,
GET/POST /v1/groups/{id}/root-keyandPUT /v1/groups/{id}/devices/{id}/root-key(a conditional move to the next id, so one of two rotations at once wins), the statements now signed over the encryption key, and the recovery copy sealed to the recovery key’s X25519 key. Client:CloudSyncsettles which root key the group is on at the start of every sync, makes the new one when a device was removed, rewrites every note and notebook key list with a new note key (SyncEngine.rewrapKeys), seals the key to each device that is behind, and finishes a rotation that was cut short; a device that has no copy waits (SyncWaitingForKeyException). 157 shared JVM tests including 8 rotation scenarios through the real server; crypto and API tests also on the iOS simulator and an API 28 emulator (2026-10-11). - Trusting the server’s list of devices (found while building this): a
rotating device seals the new root key to the keys the server lists, and an
enrolling device takes the approver’s wrap from the server (sealed with an
ephemeral key, not signed). A malicious server could add a device of its own
to what they see. Closing it: store each approval with its signature, have
clients verify the chain from a device they trust, and have the approver sign
the wrap. Needs a
D-xx; see section 9 of the design. - Merge edits that don’t overlap instead of making a conflict copy (the design’s section 7 suggests it); today any two different edits make a copy.
Epic 7: Apps (S6 Android, S7 iOS)
- Turn on sync (2026-10-11): Settings → Turn on sync asks for the server
address and a promo code (D-72), shows the 24 words once with Copy, asks for
three of them back (D-55), then
CloudSync.turnOncreates the group. A refused code or an unreachable server goes back to the first step with the same words. Both apps sync when they come back to the foreground and from Sync now. Tried between a phone and the desktop app against the test server (2026-10-11), and between miPhone and the moto (2026-10-12). - The keychain backup choice when sync is turned on (D-57). It needs the device keys stored as synchronizable (iCloud Keychain, Android Backup).
- Where the server address comes from once there is a hosted server: a
build property (
jotry.syncServerUrl, now the test server) is suggested on Turn on sync in all three apps and can be typed over. Open: how a release build gets its address (the property, or a fixed one), and whether Join with a QR code should read it from a code too. - Sync after local edits (2026-10-12): all three apps sync 5 seconds
after the last edit made on the device (
NotesCore.edits, which a sync’s own changes don’t bump), as well as on returning to the foreground and on Sync now. - Background sync (2026-10-12, D-79): Android
runs
SyncWorkerevery 30 minutes with a network (WorkManager); iOS asks for a background app refresh 30 minutes on (BackgroundSync,BGAppRefreshTask), which iOS runs when it sees fit. Both are scheduled while sync is on and cancelled when it is off. Not yet seen running on a phone. - The delete-versus-edit prompt on screen (2026-10-12, D-49, D-73): when a
sync finds a note deleted on one device and changed on another, all three
apps ask “Keep this note?” (keep the changed note, move it to the trash, or
decide later); the phones in a dialog, the desktop in a card over the window.
Whatever waits is listed under “Waiting for you” on the Sync screen with the
same answers. Tried between miPhone and the moto (2026-10-12): the
question appears, and Keep brings the note back on the device that deleted
it, as intended.
SyncDecisionnow says which side deleted and the note’s title. Not yet tried on a phone. - The notification after a background sync (2026-10-12, D-73): a background sync that finds a note deleted on one device and changed on another posts “Keep this note?” (or “N notes need a decision”); tapping it opens the app on the question. Until the system has asked for notifications, opening the Sync screen (sync on) first explains what they are for, and its only button, Continue, brings up the system’s question (owner’s wording, 2026-10-12); once the system has asked and they are off, the Sync screen says so and links to the system’s settings (D-79). Questions still waiting are asked about once each time the app starts. The desktop has no background sync. Not yet seen on a phone.
- Add a device by QR (2026-10-11, D-77): the new device picks Join with a QR code under Turn on sync, enters the server address and shows a code; a member scans it under Sync → Add a device (VisionKit on iOS, Play services’ scanner on Android). Works between a phone and the desktop app (2026-10-11) and between an iPhone and an Android phone (2026-10-12).
- Remove a device (2026-10-12, D-75): the Sync screen of all three apps lists the group’s devices (this one first, then the others from the newest, each with when it was added; devices have no names) and offers Remove on each other one, after asking. Seen on miPhone and the moto (2026-10-12): the other device showed only as “Another device”, so devices now have names (D-80, below).
- Device names and last synced (2026-10-12, D-80): each device names
itself (sealed so the server can’t read it), any member renames any device
with the pencil on its row, and the list shows when each last synced. Server
migration
V8__device_names.sql. On iOS the default name is usually just “iPhone”, so rename the iPhones. The test server runs it since 2026-10-12.CloudSync.devicesandCloudSync.removeDevice(a device cannot remove itself); the app syncs straight after, or once more after a sync already running, so the rotation happens at once. Tested on the JVM through the real server; not yet tried on a phone. - What a removed device shows (2026-10-11): the server’s 404
no-such-deviceis nowSyncNotAllowedExceptionwith reasonREMOVED, and all three apps say the device was removed from its group, that the notes are still on it, and to turn sync off and join again; Sync now and Add a device are hidden. Not yet tried on a phone. - Recovery words kept off screenshots (2026-10-11): on Android the
window is
FLAG_SECUREwhile the 24 words are shown (no screenshots, a blank Recents thumbnail). iOS cannot block screenshots; the words are covered whenever the app is not active, so the app switcher’s snapshot doesn’t show them. The desktop app does neither. Not yet tried on a phone. - After approving a device, “Device added…” went away only when the Sync screen was left. It now fades after 8 seconds on all three apps (2026-10-12); a problem stays until the next scan.
- Joining’s loose ends. The server hands the approval to the new device
once and then deletes it, so if that response is lost the device is a member
without the root key; it has to wait for a member to see it as behind, which
needs its key id recorded as 0. Approving a device cannot be undone from the
app yet. Fixed (2026-10-11): a device that turned sync off was refused when
it joined again (
device-id-taken, the server never takes an id twice); each membership now signs in with a new device id kept in the sync state (CloudSync.deviceId()), separate from the id revisions name as their author. A device already in a group keeps its old one. Moving to another group now also sends the notes received from the old group’s other devices (they used to wait in the outbox forever, with no key). Still open: two devices of one group that both move to a new group each send their shared notes under their own record ids, so the new group gets duplicates. - Recovery screen; lapsed and read-only states that show which store bills the group. A first status screen is built (last synced, Sync now, the last problem in plain words, the devices list with names, Rename and Remove, turn off sync on this device).
- Convergence tests: two devices after offline edits; then an iPhone and an Android phone.
Epic 8: Stores and billing
Start the account steps now, because they have lead time; the rest follows epic 5.
- Enrol in the Apple Developer Program, open a Google Play Console account (it may require a closed test with a minimum number of testers before production), and a Microsoft Partner Center account if Windows stays in scope.
- Create the subscription products. Enable universal purchase for iOS and macOS.
- Client purchase flow with the account token set from the group id (D-54); restore and rebind with a cooldown.
- Sandbox and test-track purchases end to end, including the Apple Sandbox and Production environments.
- Re-read Apple’s rule on multiplatform services before submission.
Epic 9: Launch readiness
- Per-group quotas, and limits on signed-in traffic if a device or group misbehaves. The open endpoints are limited already (D-69).
- Update the App Store privacy label, the Google Play data safety form and the README privacy text (“optional, end-to-end encrypted sync service; no account; we can’t read your notes”).
- Security review of the auth, key and entitlement flows (the
security-reviewskill); finalize the threat model. - Beta with your own devices, then with a few testers on promo
entitlements, against the test server (
server/compose.test.yaml).
Epic 10: Desktop sync (later)
- Desktop sync screens and the macOS Keychain store (2026-10-11, D-78):
Sync in the sidebar, turn on, join by QR code (the code is also text to
copy), add a device by pasting its code, status, Sync now, turn off; syncs
when the window opens or comes to the front. Syncs with a phone through the
test server (2026-10-11). libsodium is loaded by
SodiumLoader, because the library’s own loader fails on a jar path with a space and in the native image; the native image (JVM and native, 2026-10-11) loads it and registers a join request on the test server over HTTPS. The Keychain store and the copy buttons are not yet tried in the native image. - Desktop sync loose ends: Windows and Linux key stores (D-51) and sync on
those systems; approving a phone, whose code is only a picture (it needs a
copy-code on the phones, or reading a QR code from an image on the desktop);
a keychain item with the app’s own access list instead of the
securitytool (D-78); sync after local edits and in the background; the devices list, Remove and the delete-versus-edit prompt are built (2026-10-12). - The Mac App Store shares the Apple purchase; Windows and Linux billing wait on epic 8.
Epic 11: Hosting and going live (last)
Nothing here is needed before the server is ready to go live.
- Choose the hosting: a Ktor server on a rented VPS or container host with
managed Postgres is the working plan. Pick the provider, region and size,
write down the monthly cost, and record the choice as a
D-xx(it refines D-45 and D-46). The host and the managed Postgres provider become processors of metadata, which goes in the privacy disclosures. - HTTPS in front of the server, and deployment from the test compose setup or an equivalent.
- Backups and a restore test for the managed Postgres; monitoring and alerts.
- K4: measure start time, idle RSS and p95 latency for 100 concurrent pushes on the real hosting, against the bar in the spike (idle RSS under about 200 MB with a capped heap). If the JVM misses it, swap the adapter (http4k, Quarkus) rather than the logic.
- Update the recommendation in the spike’s section 10 to match.
Other future work
- Pasting rich text or Markdown into the editor. Today every editor pastes
plain text only: iOS (
EditorTextView.paste, which also drops\rand attachment characters), Android (JotryEditTextturns Paste into Paste as plain text) and the desktop (the Compose text field’s own paste). The text goes throughEditorEngine.replacelike typing, so line breaks become paragraphs in the current block style, but nothing else is read: bold, links and headings from a web page or another app are lost, and pasted Markdown shows its marks (**,#,- [ ]) as literal characters. A pasted web address becomes a link only on display (D-29). To decide: whether to convert HTML or rich text on the clipboard toRichText, whether to recognise pasted Markdown (always, or by a “Paste as Markdown” command), and what copy out of Jotry should put on the clipboard. Paste on a device is untested (editor.md). - Folder sync (Phase 3): Android local-folder sync first (D-06). Plan: folder-sync.md; phase in the roadmap.
- Desktop app (D-76, started 2026-10-11): first build in
app/desktopUi: sidebar, notes list with pinned notes, editor, history, search, trash and the Undo bar, in the Graphite look. Restyled to the downloaded Stitch designs (2026-10-11): a top bar with search (results in a sheet under the field) and New note, the notes as a card grid with Pinned and Recent sections, a full-width note page with a breadcrumb bar and a floating toolbar with the word count, and a history page with a day-by-day version list. Only features the apps have (D-76): the designs’ document numbers, tags, statistics, scratchpad, avatar, Archive and Preferences are not built. Rendered offscreen only so far. Next:- Try it on a real screen: run
./gradlew :app:desktopDev:runand compare with the Stitch designs (project “Jotry”, the Desktop screens). Dark theme, the sidebar’s icons and keyboard navigation of the search results are not done. - The editor: paragraph spacing (a heading touches the paragraph above), a quote bar, code blocks, IME composition and paste.
- Links in the editor. Done: Ctrl/Cmd+click opens a link (note links in the
window, web links in the system browser; a plain click only places the
caret), and typing
[[opens a note picker (Up/Down, Enter, Esc; a pasted web address becomes a web link). Still to do:- A link button in the editor toolbar (the phones’ link tool: web address or note lookup in one field, edit or remove an existing link).
- Link the selected text to a note or an address (keeps the words as the label, D-39).
- Try opening a web link in the Nucleus build, which has no AWT, and decide whether a plain click should open links too.
- Menu bar and shortcuts through Nucleus; remember window size and place; a window under about 1100 dp wide (the columns don’t adapt).
- Strings are English literals; move them to the
i18nmodule. - Notebook rename, archive and delete; moving notes by drag; import and export (Markdown) and the About/Settings screen.
- Sync: built on macOS (epic 10 below, D-78); the other systems wait on the key stores of D-51.
- Record the open choices of D-76 (native image or JVM for release, a shared Compose module, database encryption, minimum OS versions).
- The database file is still
JotrySpike/jotry.dbin the data folder; rename it before anyone keeps notes there.
- Try it on a real screen: run
Open decisions
These come from 05-decisions-and-open-questions.md.
Some may already be answered by how the app behaves. They close only when a
D-xx records the answer.
- Before Release 1:
- How long the Undo bar stays. Both apps use 6 seconds (what it covers is
D-24); record it as a
D-xx. - What are the revision retention limits?
- How long the Undo bar stays. Both apps use 6 seconds (what it covers is
D-24); record it as a
- Before folder sync: which providers we support, how external edits merge, what a deleted managed file means, whether iOS has a sync destination, front matter in synced files, and file names and wikilinks.
- Before cloud sync:
- Confirm how the sync engine merges (built in epic 6, not recorded as a
D-xxyet): two different edits keep this device’s version and turn the other into a conflict copy; a notebook changed on two devices keeps the version pushed last; the default notebook is never a record (each device maps it to its own); a revision once synced is never replaced, so saves within a minute stop folding into one version once it is synced. - The hosting direction and how the managed Postgres is backed up (epic 11).
- Prices and trial length.
- Shared notebooks.
- Whether clients should verify the chain of device approvals, and the approver sign the wrap it sends (see Epic 6; the limit is written down in D-74).
- Settled: recovery (D-48, D-55, D-75), delete-versus-edit conflicts (D-49), no size padding (D-50), desktop key storage (D-51), business model and cross-store purchases (D-52 to D-54), lapsed groups (D-56), where the delete-versus-edit prompt appears (D-73), device encryption keys and how a rotated root key reaches the others (D-74, D-75).
- Confirm how the sync engine merges (built in epic 6, not recorded as a
Spikes waiting for a decision
These are spikes and proposals in docs/spikes/. None is agreed yet, except
where a D-xx says so.
- Folder sync
- Cloud sync and E2EE: options and the older work order.
- Sync system design: enrollment, auth, encryption at rest and syncing.
- Monetization and sync tiers
- Desktop app: Linux, macOS and Windows with Nucleus
(the build started under D-76; the plan’s other options are still proposals).
The spike in
app/desktopUi,app/desktopAppandapp/desktopDevbuilds and runs natively on all three (CI 2026-10-08). The ComposeTextEditor spike (local, no CI) found it workable as a text surface but not worth its second model; the recommendation is to keep our own editor. The plan also covers a Rust UI over a C ABI (not recommended) and JetBrains’ kotlin-desktop-toolkit (not usable yet; recheck every few months).
Test gaps
The full list is in docs/tests: the Gaps section of each area file, plus untested-areas.md. The ones closest to recent work:
- The sync engine is tested on the JVM only (against the fake server and the real one in process), and nothing tests records waiting for their key, how the engine carries on after a refused write or a read-only group, or a removal racing an enrollment (sync-engine.md).
- Importing single files from Settings, and the unreadable-file warning.
- App closing before the Empty trash undo bar expires (D-40).
- Android: opening the drawer with a left-edge swipe, and the empty-search screenshot.
- The CI workflow only runs when started by hand.
Housekeeping
Dev machine, not in the repo:
- The old iOS 18.5 simulator runtime is still installed (about 8 GB). Nothing
in the project uses it. It isn’t managed by Xcode, so removing it means
unmounting
/Library/Developer/CoreSimulator/Volumes/iOS_22F77and deleting its image bundle under/Library/Developer/CoreSimulator/Cryptex/Images.
Product and Scope
Product statement
Build a private, reliable note-taking app in which people write and retain notes as Markdown, group them into notebooks, and can recover or inspect every saved version. The initial release provides native experiences on Android and iOS, while sharing business logic through Kotlin Multiplatform (KMP).
The app must work without a network connection. The initial release is fully local: it does not connect to a sync destination. Its data model and module boundaries will nevertheless leave a deliberate path for a future sync system, while the application remains the authoritative owner of local notes and revision history.
Product principles
- Local first. Creating, editing, searching, and viewing notes must not depend on an account, connection, or remote service.
- Markdown is the document format. A current note is represented as portable UTF-8 Markdown, with app-specific metadata kept separate where possible.
- History is never silently lost. Every durable content change creates a locally retained revision. Pruning, if introduced later, is explicit and recoverable where possible.
- Native feel, shared rules. Android and iOS use platform-native UI and input behavior; domain rules, persistence contracts, future-sync boundaries, and test cases live in KMP.
- Sync is designed, not shipped. Future destinations will declare their capabilities; a file-copy destination must not be represented as offering remote history or conflict-free collaboration.
- Extensions are constrained integrations. A future plugin system will extend defined capabilities without gaining unrestricted access to notes or app internals.
Primary users and jobs
| User need | First-release capability |
|---|---|
| Capture and revise a thought quickly | Create/edit Markdown note with autosave and offline persistence |
| Keep related material together | Create, rename, archive, and browse notebooks; notes created outside a notebook live at the top level; pin important notes |
| Clean up or reorganize many notes at once | Press-and-hold multi-select with select all, then move or delete together |
| Capture a thought instantly | Quick capture from the app icon’s long-press menu: blank note, keyboard up, no title or notebook to choose |
| Never name notes by hand | Automatic titles from an on-device model when available, otherwise the first line |
| Fix a mistake without being interrupted | Undo bar after delete or move instead of confirmation dialogs |
| Connect related notes | Type [[ to link a note; tap a link to open it, back returns |
| Jump to another note while reading one | Fuzzy note lookup from inside a note; back/forward move between the notes visited |
| Recover an earlier draft | Browse a note’s revision timeline and restore or copy a prior revision |
| Write formatted notes without leaving the keyboard | Keyboard accessory formatting toolbar |
| Track a small set of actions inside a note | Create Markdown checklists and check/uncheck items directly in the rendered/editor experience |
| Keep notes available elsewhere | Future: connect a supported sync destination and see its state |
| Maintain a folder of Markdown files on Android | Future: install/use a local-folder sync extension |
Release 1 scope
In scope
- Android and iOS apps with native UI.
- Notebook list, note list, note editor, note detail/history view, and settings.
- A live-preview, rich editing experience on Android and iOS. Markdown is the canonical stored format, but its syntax is not exposed in the normal editing or reading UI.
- Keyboard accessory toolbar for common Markdown actions: heading, bold, italic, strikethrough, checklist, bullet list, numbered list, quote, code, link, undo, and redo.
- Interactive task lists: users can add checklist items and check or uncheck them in a note. Toggling updates the underlying Markdown representation and is saved as a normal note revision.
- Local encrypted-at-rest storage when platform support is selected; at minimum, secure platform storage for secrets and a documented local-data protection posture.
- Immutable local revisions for note content and important note metadata changes.
- Search across local current notes.
- A documented future-sync placeholder: stable IDs, local revision parentage, portable Markdown metadata boundaries, and adapter/capability contracts. It has no configured destinations, transfers, permissions, status UI, or background work in Release 1.
- Export/import of current Markdown notes and notebooks, with documented behavior for history.
Explicitly out of scope for Release 1
- Real-time collaborative editing and shared notebooks.
- Any operational sync destination, cloud account, remote transfer, sync status UI, or background sync.
- Android local-folder sync and installation/distribution of sync plugins.
- A proprietary hosted cloud service or user accounts.
- Desktop or web clients.
- Arbitrary third-party executable plugins on iOS.
- End-to-end encrypted multi-device cloud sync unless a specific cloud destination is selected and designed.
- Rich-text document storage as the source of truth; Markdown remains canonical.
- Attachments beyond references/links. Attachment storage can be designed later without blocking Markdown notes.
Experience requirements
Core navigation
The user can move from the top-level list to a note editor in one or two actions. A note may be moved between notebooks.
Organization layout
The hierarchy has exactly two levels:
- Top level holds notebooks and the notes that are not in any user-created notebook.
- Notebooks hold only notes. A notebook cannot contain another notebook, so notebooks never nest.
Every note belongs to exactly one notebook. A note created outside any notebook goes into a built-in default notebook. The default notebook is never displayed as a notebook: its notes appear at the top level as if they were unfiled. The user cannot rename, delete or archive it (see D-15).
Pinned notes. The user can pin and unpin notes.
- Pinned notes in the default notebook are shown at the very top of the top-level list, above notebooks and unpinned notes.
- Pinned notes in a user-created notebook are shown at the top of that notebook’s note list only, not on the top-level list.
- Among pinned notes in the same list, the one most recently edited is first.
- Pinning or unpinning is not an edit: it creates no revision and does not change the note’s last-edited time or its position among other pinned notes.
Deleting a notebook. Deleting a notebook never deletes its notes. They move to the default notebook and appear at the top level. A user who wants the notes gone too deletes them first (see multi-select below) and then deletes the notebook.
Multi-select. In any note list (top level or inside a notebook), pressing and holding a note enters selection mode with that note selected. In selection mode the user can select or deselect further notes by tapping, choose Select all, and then Move the selected notes to another notebook (including the default notebook) or Delete them together. Delete follows the normal trash and restore rules. Leaving selection mode (back or a cancel control) changes nothing.
Quick note lookup and note-to-note navigation
While viewing or editing a note, the user can open a lookup that fuzzy-searches notes and jump directly to a result, without first leaving to the notebook or note list. Results are matched against note titles first and then note bodies: title matches rank above body-only matches, and within each group better matches rank higher.
Navigation after a lookup behaves like a browser history:
- Jumping from note A to note B via lookup opens B.
- Back returns to note A, not to the notebook list or out of the note.
- Forward, after going back, returns to B.
- Repeated lookups extend the history in the same way (A → B → C; back goes to B, then A).
The history has no fixed length limit within a session. It is not persisted: when the app is closed and reopened, it opens on the note that was open last, with an empty history. When the history has no earlier note, back leaves the note as it does today. Opening a lookup result while there is unsaved text in the current note saves that note first (D-14).
Quick capture
Getting a thought down must take no decisions. Quick capture opens a blank new note with the keyboard already up and nothing else to fill in: no title, no notebook choice. The note goes into the default notebook, and the user can move it later.
- App-icon quick action. Long-pressing the app icon (Android app shortcut, iOS Home Screen quick action) shows a Quick capture action. Choosing it launches the app straight into a blank note as above.
- An empty note is never kept: leaving a quick-capture note without typing anything discards it and creates no revision.
- Quick capture follows the normal autosave rules (D-14).
Widgets, share-sheet capture and other entry points are future options, not Release 1.
Automatic titles
The user never has to type a title. The app creates one from the note’s content:
- If the device offers a local, offline language model, the app asks it for a short title for the note. Nothing is sent off the device. The target models are Apple’s on-device Foundation Models (iOS 26+ on Apple Intelligence-capable devices) and Android’s Gemini Nano through ML Kit GenAI / AICore on supported devices. Availability varies by device, OS version and settings, so the app checks it at runtime.
- If no such model is available, or it is not ready, fails or returns nothing usable, the app uses the first line of the note as the title. This is also the immediate title shown before a generated one arrives.
Generating a title never blocks typing or saving, and the first-line fallback is always available. While the title is automatic, a generated title replaces the first line a few seconds after the note is opened, saved or has its title cleared. A title the user typed is never replaced automatically. The user can ask for a new one at any time with Generate title in the title dialog; it is shown only when the model is ready. Titles are written in the note’s language when it can be detected, otherwise in the device’s language. See D-23, D-28 and D-30.
Undo instead of confirmation
Destructive actions do not ask “Are you sure?”. They happen immediately and show a brief Undo bar at the bottom of the screen. This applies to deleting a note, deleting several selected notes, and deleting a notebook. Tapping Undo puts everything back exactly as it was: deleted notes return to where they were, and a deleted notebook returns with its notes in it. If the bar times out or is dismissed, the action stands under the normal trash rules (notes can still be restored from Trash; see D-21 for notebooks). Moving notes shows the same bar so a wrong move is one tap to reverse.
Trash. The Trash screen’s More (three dots) menu offers Select and Empty trash. Notes can be selected there (press and hold, or Select) and restored together with Restore selected. Empty trash works like the other destructive actions: the notes disappear at once and an Undo bar appears. They are deleted forever, with their history, only once the bar has gone; if the app closes first, they stay in the trash (D-40).
Links between notes
Users can link one note to another while writing, and tap a link to go there.
-
Creating a link. Typing
[[in the editor opens the same fuzzy note lookup used elsewhere (D-17). The link toolbar button opens one text field, with no choice to make first. The user types, and the app decides what kind of link it is:- If the text looks like a web address (it starts with a scheme such as
http://,https://ormailto:, starts withwww., or looks like a domain such asexample.com/page, with no spaces), it becomes a web link. The field shows the address as the top suggestion, and confirming inserts it. - Otherwise it is treated as a note link: the field shows matching notes from the fuzzy lookup, and choosing one inserts a link to that note at the cursor. When nothing matches, the field offers to create a new note with the typed text as its title and link to it.
- If the text is selected in the note when the button is pressed, it becomes the link’s label and the field starts with that text.
- If the text looks like a web address (it starts with a scheme such as
-
Addresses typed as text. In the read view, a web address written as plain text (starting with
https://,mailto:… orwww.) is shown as a link and opens when tapped. The note’s text is not changed (D-29).Typing
[[always means a note link and skips the web-address check. -
Appearance. A note link looks like a link in the live preview; the user never sees link syntax.
-
Following a link. Tapping a note link opens the target note. It counts as a jump in the note history (D-17): back returns to the note the user came from, and forward returns to the link’s target.
-
Stable. A link points to the target note by its ID, so renaming or moving the target does not break it.
-
Label. A link shows the target’s current title, so it changes when the target is renamed. If the user linked selected text or typed inside the link’s label, the link keeps those words (D-39).
-
Missing targets. If the target note is in Trash or deleted, the link is shown as unavailable and tapping it says why, with a one-tap Restore when the note is in Trash.
See D-25 and the data-model section on note links for storage.
Editing and formatting
The editor is always a live preview: headings, emphasis, lists, links, quotes, code, and task checkboxes are displayed as their formatted forms while the user edits. Markdown remains an implementation detail used for storage, export, import, and future sync; there is no source-editing mode or Markdown-syntax toggle in the product UI.
Formatting commands apply a semantic operation to the current selection, block, or cursor position, with predictable cursor placement and behavior on an external keyboard. The editor serializes the resulting document back to canonical Markdown without exposing that serialization. Native text editing behavior, accessibility, spell-check, selection handles, and input-method integration stay in each platform UI.
Task-list items appear as accessible checkbox controls, not Markdown markers. Toggling preserves surrounding formatted content, works offline, and creates a revision only when the checked state actually changes. Nested task items are supported as nested visual lists; task scheduling, reminders, recurrence, and cross-note task views are future extensions, not Release 1 requirements.
Revision history
The history screen shows timestamp of the change in HH:MM format in local timezone. There is no second level history. The user can preview a revision, copy it into a new note, or restore it as a new current revision. Restoring never overwrites the old current revision. The preview shows what that version changed compared with the version before it, each change with the rest of its line (D-37, D-38), above the version’s full text.
History lists only versions that came from writing, restoring, importing or copying. Moving a note to another notebook, to the trash or back from the trash is not shown, and restoring a version never moves the note (D-43).
Each version shows what changed compared with the version before it (D-37). Each change appears with the unchanged rest of its line before and after it, at most about 100 characters on either side (D-38). Deleted text has a mild red background and added text a mild green background. Formatting changes are not shown as changes. A version whose only changes are formatting says so. The first version shows its whole text as added.
Future-sync design boundary
Release 1 exposes only local-save state. During design, preserve stable note and revision identities, current-revision parentage, and a portable Markdown/export representation so a future destination can be introduced without rewriting user data. Sync settings, connection flows, destination status, retries, and permission errors are deferred until an actual destination is built.
Success criteria
Release 1 is ready for a limited beta when:
- Notes can be created, edited, moved, searched, and deleted/restored locally without data loss across app restarts.
- Each saved edit produces an inspectable local revision, and restoring a revision results in a new revision.
- The editor toolbar produces predictable formatted results for selected text and empty selections, including task-list insertion; the saved Markdown remains valid without being shown to the user.
- Users can check and uncheck task items on Android and iOS; the persisted Markdown, revision history, and search index reflect the resulting state.
- The documented placeholder captures the future sync capability model without shipping any destination implementation or network behavior.
- Core rules have automated KMP tests; Android and iOS have targeted UI/integration coverage.
Architecture and Data Model
Recommended shape
Use a Kotlin Multiplatform modular core, with native Android and iOS presentation layers. Keep UI-specific editor and navigation code outside shared code; share the domain model, use cases, persistence abstractions, future-sync contracts, and test fixtures. Release 1 does not contain a running sync engine or destination adapter.
Android native UI (Kotlin) iOS native UI (Swift/SwiftUI or UIKit)
| |
+---------- KMP presentation-facing use cases ----------+
|
domain model | revision service | search | future-sync contracts
|
repository and capability interfaces
| | |
local database (future adapters) platform services
Suggested modules
| Module | Responsibility |
|---|---|
shared:domain | Entities, validation, use cases, conflict and revision rules |
shared:data | Repository implementations, serialization, database mappings, migrations |
shared:sync-contracts | Placeholder-only capability and adapter contracts; no coordinator, queue, or destination implementation in Release 1 |
shared:extensions-api | Placeholder-only stable extension and permission contracts; no plugin installation/runtime in Release 1 |
shared:search | Indexing/query interfaces and common query behavior |
android:app | Android UI, lifecycle/background work, storage access framework integration |
ios:app | iOS UI, lifecycle/background tasks, security-scoped/bookmarked location handling if applicable |
Keep module boundaries enforceable: platform apps call use cases; adapters implement ports; the domain layer does not import platform UI or destination SDKs.
Canonical data model
Live-preview editing model
Markdown is the canonical persisted and interchange representation, not the editor UI model. Each native app presents a structured, live-rendered document projection and sends semantic edits—such as “toggle bold for this selection,” “make this block a heading,” or “check this task”—to shared editing/domain logic. That logic updates the document and serializes valid Markdown when the note is saved.
- Do not expose a raw-Markdown editor, syntax toggle, or Markdown control characters in normal note UI.
- Preserve the user-visible selection, cursor, undo/redo behavior, and accessibility focus across structured edits.
- Keep parsing and serialization deterministic so an unchanged rich document does not produce noisy Markdown/revision changes.
- Treat unsupported or malformed Markdown from import/future sync as a recoverable compatibility case: preserve the source safely and render the supported subset without destructive rewriting.
Platform UIs own text-input integration and visual rendering. Shared code owns the canonical document transformations, Markdown codec contract, and conformance tests that verify rich-edit operations round-trip to Markdown correctly.
Note
| Field | Notes |
|---|---|
noteId | Stable UUID/ULID generated locally |
notebookId | Owning notebook; always set. Notes created outside a notebook use the built-in default notebook (see D-15) |
pinnedAt | Nullable timestamp; non-null means the note is pinned (see D-16). Pinned notes list first, ordered by most recent edit. Changing pinnedAt is not an edit: it creates no revision and leaves updatedAt unchanged |
title | Stored separately from the body. Generated automatically (D-23): by an on-device language model when one is available, otherwise from the first line of the note |
titleSource | How the title was set: firstLine, generated or user. Lets the app avoid overwriting a title the user chose |
markdown | Current UTF-8 Markdown document body |
createdAt, updatedAt | UTC timestamps for display and future ordering hints |
deletedAt | Tombstone timestamp; supports local recovery and future deletion synchronization |
currentRevisionId | Points to the current immutable revision |
originDeviceId | Non-user-identifying install/device identity for diagnostics and conflict display |
Note links
A link from one note to another is stored in the Markdown as a standard link whose destination is an app-defined address containing the target noteId (for example jotry://note/<noteId>), with the visible text as the link label. Because the target is addressed by ID, renames and moves do not break links. Shared code owns parsing and creating these links and keeps a local index of outgoing links per note so a missing or trashed target can be detected without scanning bodies. A link follows the target’s current title unless the user chose its label, which the address marks as jotry://note/<noteId>?label=custom (D-39). Shared code relabels links when a note is shown or opened for editing; the stored label catches up the next time the linking note is saved, so a rename creates no revisions in other notes. Exported files write a note link as a relative link to the target’s file, for example [Ideas](../personal/ideas--<noteId>.md); a link to a note that isn’t exported keeps its label as plain text. Import turns relative links to imported files back into note links (D-36). Backlinks (which notes link here) can be derived from the same index later, but are not a Release 1 requirement.
Notebook
| Field | Notes |
|---|---|
notebookId | Stable UUID/ULID |
name | User-visible name, unique only if product rules require it |
createdAt, updatedAt, archivedAt | Lifecycle timestamps |
sortOrder | User-controlled order, represented in a future-sync-safe way |
isDefault | True for the single built-in default notebook. It is hidden in the UI and cannot be renamed, archived or deleted (D-15) |
There is no parent-notebook field: notebooks are flat and hold only notes (D-15).
Revision
Each revision is immutable. It records a complete snapshot, which is simpler and safer than an operation log or a delta chain (D-37). The changes shown in version history are diffed from snapshots when viewed and are not stored. If storage measurements show a need, unchanged bodies may be stored once and shared between revisions.
| Field | Notes |
|---|---|
revisionId | Stable ID |
noteId | Parent note |
parentRevisionId | Previous known revision; enables a revision graph when conflicts occur |
markdownSnapshot | Exact document content at this revision |
titleSnapshot, notebookIdSnapshot, deletedAtSnapshot | Important state needed to recover the note faithfully |
createdAt, authorDeviceId, reason | Audit/display data; reason examples: edit, restore, import, merge |
contentHash | Detect unchanged saves and validate transfers |
Future sync metadata boundary
Release 1 stores no per-destination state, tokens, remote versions, or pending operations. When a destination is added, its metadata must live outside the Markdown document—keeping files portable and preventing sync internals from leaking into user content.
Parsed task items
Task items are not a second source of truth. Their checked state is part of the canonical Markdown serialization but is presented as a visual checkbox in the app. A parser/index may derive task-item data to support editor rendering, accessibility, and search:
| Derived field | Notes |
|---|---|
noteId and source range/anchor | Lets the UI locate the task item in a particular note revision/current body |
text | Human-readable task label |
completed | Derived from the serialized checkbox marker; never displayed as source syntax in the app |
nestingLevel | Represents nested Markdown task lists |
The toggle use case must target the current structured task node safely, update only its checked state, and reject/stage a refresh if the editor content changed before it applies. A successful toggle updates the full note through the normal transactional revision path.
Storage model
- Use a local transactional database for notes, notebooks, revisions, and local search/index data. The chosen database is SQLite via SQLDelight (D-09). Add destination metadata and an outbox only when operational sync is implemented.
- Store each note’s current body and revisions atomically with its metadata; a visible update must never exist without its revision.
- Commit editor changes using a short debounce plus lifecycle flush. A save only creates a revision if the meaningful state changed.
- Treat deletion as a tombstone, not immediate physical removal. A retention policy can later account for destination acknowledgements when sync exists.
- Maintain a migration strategy from day one, including migration test fixtures for every released schema.
Markdown file representation
Markdown export follows D-42: one file per current note, named after its title, in a folder per notebook, with the default notebook’s notes at the root:
<export-root>/
<Note title>.md
<Notebook name>/
<Note title>.md
Export always writes YAML front matter with portable identity and organization metadata only (note_id, notebook_id, notebook, title, created_at, updated_at, pinned). It never holds revision snapshots or authentication data. The user’s Markdown body is kept exactly below the front matter. Identity comes from note_id, not the file name, so renamed notes and repeated titles stay unambiguous.
The layout recommended here earlier (<notebook-slug>/<note-slug>--<noteId>.md) was replaced by D-42. Import still reads it. How folder sync stores identity (a note-to-file table rather than front matter) is planned in docs/spikes/folder-sync.md, not decided.
History and conflict rules
- A local edit creates a new immutable revision in one transaction.
- Checking or unchecking a task item is a local edit: it changes canonical Markdown and creates an immutable revision.
- A restore produces a new revision whose content equals the selected historic revision, preserving the current revision as its parent.
Future sync must add outbox operations and remote reconciliation without changing these local history guarantees. A remote revision that descends from the local current revision may fast-forward; divergent branches must be preserved rather than silently discarded. A destination without remote history may exchange only current documents while local history remains complete.
Security and privacy baseline
- If/when sync is added, keep credentials and destination tokens in the platform secure-storage facility, never in Markdown files or logs.
- Minimize collected telemetry; make diagnostics opt-in and redact note content.
- Define encrypted transport and end-to-end encryption separately for any future cloud sync design.
- Clearly explain what a future destination can read, write, retain, and recover before it is enabled.
Future Sync and Extension Platform
Sync model
The app owns the local source of truth and its full revision graph. This document is a future-design reference, not an initial-release feature specification. Release 1 ships no sync coordinator, destination adapter, connection settings, transfer queue, or plugin runtime. It preserves a small set of contracts and data boundaries so later work can introduce those components without breaking local data.
future workflow:
local edit -> transaction: current note + immutable revision + outbox event
|
sync coordinator
|
+------------------------------+------------------------------+
| |
history-aware destination file/current-state destination
can exchange revision graph exchanges current .md + metadata
Placeholder contract for future destinations
Every future destination should declare a capability document rather than relying on its name or implementation type. These declarations may live as documentation/types in Release 1, but no adapter executes them.
| Capability | Meaning |
|---|---|
remoteHistory | Destination stores and returns immutable revisions/parents |
remoteTombstones | Destination can propagate deletions safely |
stableItemIds | Files/items keep a stable identity across rename/move |
changeTokens | Destination can provide incremental change discovery |
atomicWrite | Destination can commit an item without exposing partial content |
backgroundSync | Adapter can operate under the platform’s background constraints |
bidirectional | Adapter can both upload and import changes |
conflictSignals | Adapter exposes version/ETag/precondition information |
When implemented, the coordinator must use conservative behavior when a capability is unavailable. For example, a folder destination without remoteHistory must not claim remote version recovery, and a destination without conflictSignals should surface potentially concurrent changes instead of overwriting them optimistically.
Future adapter operations
The API should use domain-neutral values and avoid exposing database entities directly. A minimum contract includes:
describeCapabilities()— return the capability document and user-facing destination description.authorize()/deauthorize()— obtain and remove access; secrets remain platform-owned.discover(cursor)— list remote changes incrementally where supported, or perform a bounded scan.fetch(item)— retrieve a current document or revision payload.push(change, precondition)— write a change, with a remote version precondition when supported.delete(item, precondition)— propagate tombstones when supported.getStatus()— report connection health, last action, retryability, and user action needed.
Define transfer payload versions from the first implementation. Future apps must be able to recognize an unsupported payload and leave data intact rather than corrupting it.
Future sync lifecycle
- Local commit: save note and revision; enqueue an idempotent outbox event.
- Discovery: collect destination changes using a change token, version manifest, or constrained folder scan.
- Reconciliation: identify items using stable note IDs; compare revision parent/hash/version metadata.
- Apply: fast-forward safe remote changes or stage conflicts; never replace an unacknowledged local change.
- Upload: push queued local changes with preconditions when available.
- Acknowledge: update per-destination metadata only after the remote action succeeds.
- Report: expose pending count, last successful completion, meaningful warnings, and recovery actions.
Make operations idempotent. Sync may be interrupted, retried, or run concurrently by foreground and background scheduling.
Future Android local-folder sync extension
This is the planned first sync extension and should act as the reference implementation for the extension contract when that future phase begins.
User setup
- User selects a folder through Android’s Storage Access Framework.
- The app stores the persistable permission URI and validates read/write access.
- The extension writes a small app manifest (for example
.notes-app-manifest.json) in the selected root to identify its managed layout and format version. - The user is told that the folder preserves current Markdown notes, not the app’s complete revision history.
File behavior
- Export every current, non-deleted note as deterministic
.mdwith portable front matter. - Do not modify files outside the extension’s managed directory/layout.
- Write via a temporary file plus replace/rename if the selected provider supports it; otherwise verify the completed content hash after write.
- Detect changed files by metadata/hash and import them as new local revisions.
- Treat an externally deleted managed file as a potential deletion request only after clear conflict checks; offer a safe confirmation/recovery path.
- Keep a local mapping of note ID to provider document URI/version. Do not depend on filenames as identity.
Limitations to state explicitly
- Folder copies/backups may retain historical versions, but the app cannot assume or manage them.
- Concurrent edits on two devices can create conflicts; the future implementation must preserve both local and imported content rather than auto-merging silently.
- Android background folder access and provider behavior vary. Failed background work must be retryable from the app.
Extension strategy
Release 1: placeholder only
Ship only documented/stubbed capability and extension contracts where they help preserve module boundaries. Do not package an extension registry, Android local-folder implementation, destination configuration, or third-party plugin runtime. This avoids premature lifecycle and security commitments while retaining a clear implementation path.
Future Android distribution
Consider separately installed Android companion apps or signed packages only after defining:
- signature/trust policy and compatibility/version negotiation;
- narrow IPC contracts and permission grants;
- install/update/disable behavior;
- background execution and crash isolation;
- privacy disclosure, review, and revocation UX.
iOS constraint
iOS does not support arbitrary code plugins in the same model. On iOS, extensions should be built-in integrations, app extensions where Apple permits them, or protocol-compatible sync services. The shared capability contract still applies, but distribution is platform-specific.
For the broader set of expected plugin categories—templates/capture, metadata-driven views, research integrations, automation, and AI—see the extension design reference. These surfaces should use their own narrow contracts rather than overloading the sync API.
Future cloud destination requirements
Before committing to cloud sync, select a conflict model and threat model. A suitable history-aware cloud destination should support stable item IDs, revision parent links, change tokens, conditional writes, tombstones, and encrypted transport. If end-to-end encryption is required, design key generation, recovery, device enrollment, metadata leakage, sharing limits, and multi-device conflict recovery before implementation.
Delivery Roadmap
Delivery approach
Deliver a narrow, trustworthy local-first app before implementing synchronization. The sequencing below deliberately validates irreversible foundations—identity, revisions, persistence, and editor behavior—before adding destinations or new client platforms.
Phase 0 — Product and technical foundations
Outcome: decisions that prevent rework in the core model.
- Confirm target minimum Android/iOS versions and native UI technology choices.
- Choose local database, KMP concurrency approach, Markdown parser/renderer, and dependency policy.
- Write UX flows and wireframes for editor, history, local recovery, and task lists; document future sync/folder-conflict flows without building their UI.
- Finalize canonical Markdown/front-matter format and compatibility/versioning policy.
- Use the extension design reference to identify stable data, command, permission, and provenance contracts before exposing public extension APIs.
- Define revision retention policy and privacy/security baseline.
- Establish CI, code style, modular project skeleton, test fixtures, and crash/error reporting policy.
Exit criteria: Architecture decision records (ADRs) are approved; sample Markdown files round-trip; test app can call a shared KMP use case from both platforms.
Phase 1 — Local-first core
Outcome: a dependable single-device note app.
- Implement notebook and note CRUD, the hidden default notebook, pinned notes, multi-select move/delete, and move/archive/delete/restore flows with an Undo bar instead of confirmations.
- Implement quick capture, including the app-icon long-press action on both platforms.
- Implement automatic titles: first-line fallback everywhere, plus an on-device model behind a runtime availability check (Apple Foundation Models, Android Gemini Nano via ML Kit GenAI).
- Implement fuzzy note lookup, note-to-note links (
[[, tap to follow), and the per-session back/forward note history, with the last open note restored on launch. - Implement transactional persistence, schema migrations, stable IDs, and tombstones; reserve clean module/data boundaries for a future outbox rather than implementing one.
- Implement immutable revision snapshots, revision browsing, comparison/preview, and restore-as-new-revision.
- Build native live-preview rich editors on both platforms, backed by a shared Markdown codec and semantic document-edit operations. Do not ship a source-editing mode.
- Add interactive task lists: create, check, and uncheck items through visual controls; serialize their state to canonical Markdown; support nested lists and accessible controls.
- Add editor autosave, undo/redo strategy, task-aware search indexing, and native accessibility support.
- Add keyboard formatting toolbar with selection-safe command behavior, including checklist insertion.
Exit criteria: Offline acceptance tests pass across app restart; history recovery is verified; task items can be checked/unchecked on both platforms and remain correct after restart; no-edit save does not create duplicate revisions; accessibility and keyboard flows have manual QA coverage.
Phase 2 — iOS polish and beta readiness
Outcome: a coherent native iOS and Android beta with dependable local functionality.
- Finish iOS editor toolbar, history, navigation, and platform-specific lifecycle handling.
- Perform migration, storage, performance, accessibility, localization-readiness, and privacy review.
- Recruit a limited beta, collect opt-in diagnostics, and define data-recovery support procedures.
Exit criteria: Beta release checklist is complete; known limitations and recovery steps are published; success metrics are instrumented without collecting note contents.
Future Phase 3 — Sync platform and Android folder sync
Outcome: safe, understandable current-state file sync on Android.
- Implement destination registry, capability contract, sync coordinator, retry queue, status model, and diagnostics.
- Implement managed folder layout, deterministic exporter/importer, identity metadata, and hash/version comparison.
- Integrate Android Storage Access Framework permissions and background scheduling.
- Build conflict staging and recovery UI; test interruptions, revoked access, duplicate filenames, renames, external edits, and external deletes.
- Add import/export guides and user-facing capability disclosures.
Exit criteria: A test matrix demonstrates no silent data loss across interrupted sync, external edits, and folder re-selection; UI correctly states that folder sync does not provide app-managed remote history.
Extension sequencing note
The plan intentionally excludes content-tool plugins (for example drawing, diagram, and rich visual-document tools) from the current extension design target. No extension implementation or plugin runtime is in Release 1. After a future folder-sync implementation, prioritize constrained templates/capture and import/export primitives. Only then consider metadata-driven views, research connectors, AI providers, and—on desktop—advanced automation, as described in the extension design reference.
Future Phase 4 — History-aware cloud sync
Outcome: multi-device sync that can transport revision history safely.
- Select cloud/storage model and account/identity approach.
- Implement encrypted transport, conditional writes, incremental discovery, tombstones, and history graph transfer.
- Add device enrollment, conflict-resolution UX, sign-out/data-retention behavior, and recovery documentation.
- Run reliability tests for offline edits on multiple devices, partial upload, large histories, and account recovery.
Exit criteria: Cross-device scenarios preserve every branch or make conflicts recoverable; security review approves the selected threat model.
Future Phase 5 — Desktop and web clients
Outcome: additional clients use the same domain and sync protocols without weakening data guarantees.
- Extract/strengthen platform-neutral KMP APIs for desktop targets.
- Build desktop native/Compose UI after validating editor and local-storage needs. Started on 2026-10-11 (D-76): Linux, macOS and Windows together, Compose UI in
app/desktopUi. - Choose web architecture deliberately: Kotlin/Wasm/JS shared-domain options, browser storage limits, offline support, and secure credential handling.
- Reuse the sync protocol and file format; add client compatibility testing and migration controls.
Exit criteria: New clients round-trip notes and revisions safely with existing mobile clients; platform-specific constraints are documented.
Continuous quality work
| Area | Required practice |
|---|---|
| Core correctness | KMP unit/property tests for revision, merge, ID, serialization, and migration rules |
| Future sync | Once implemented, contract tests run against every destination; fault injection covers timeout/retry/duplicate events |
| UI | Native UI tests for editing, task toggle, formatting selection, history restore, conflict and permissions flows |
| Data safety | Backup/export/import drills with representative datasets and corrupted-file fixtures |
| Performance | Measure startup, editor latency, search, and history size; add initial/repeated sync measurement when sync exists |
| Security | Dependency updates, secret scanning, least-permission review, and threat-model reviews before cloud work |
Initial backlog ordering
- Project skeleton and ADRs.
- IDs, data schema, migrations, repositories, and revision transaction.
- Notebook/note screens and native live-preview editor.
- History browser and restore flow.
- Search and editor toolbar.
- Beta hardening and iOS parity.
- Future: sync interfaces, outbox, capability UI, and test harness.
- Future: Android local-folder extension.
Decisions and Open Questions
This register separates agreed direction from choices that should be resolved before implementation commits the team to a costly path.
Recorded decisions
Struck-through text in a decision has been superseded; the note after it says by which decision. Each ID carries its tags on a second line. Tags: architecture structure and tech stack, core data model and behavior of notes and notebooks, storage local database, format file and Markdown formats, editor the text editor, history revisions, ux screens and interaction, links note and web links, titles automatic titles, export Markdown export and import, i18n localization, branding name and icon, security on-device protection and backups, encryption end-to-end encryption and keys, sync sync between devices, cloud hosted sync server, billing paid sync and the stores, roadmap scope and phasing, android / ios / desktop platform-specific.
| ID and tags | Decision | Rationale |
|---|---|---|
D-01architecture | Use Kotlin Multiplatform for the core and native UI on Android/iOS. | Shares correctness-critical behavior while preserving native editing and accessibility. |
D-02core, format | Markdown is the canonical note content format. | Supports portability, readability, and file-based destinations. |
D-03core | Notes belong to notebooks. Refined by D-15: a hidden default notebook holds notes created outside any notebook. | Provides the primary organizational model. |
D-04core, history | Local revision history is mandatory for every note. (Refined by D-14.) | Makes recovery reliable independently of sync destination features. |
D-05sync | Sync destinations advertise explicit capabilities. | Avoids assuming that all destinations retain history or support the same safety guarantees. |
D-06sync, roadmap | The first future sync extension target is Android local-folder sync. | It directly supports a useful user workflow, but is explicitly deferred from Release 1. |
D-07roadmap | Desktop, web, and cloud sync are later phases. | Keeps the first delivery focused on local correctness and safe sync foundations. |
D-08editor, format | Interactive task lists are a Release 1 feature; their state is stored in standard Markdown checkboxes. | Keeps tasks portable and ensures checking/unchecking participates in local history and future sync naturally. |
D-09core, storage | Local storage uses SQLite through SQLDelight, owned by the shared KMP core. | SQL-first, type-safe, mature on Android and iOS, with build-time migration verification. See D-09 details. |
D-10security, android | Android cloud backup runs only when it is end-to-end encrypted: disableIfNoEncryptionCapabilities="true" in data_extraction_rules.xml (Android 12+) and requireFlags="clientSideEncryption" in backup_rules.xml (Android 11 and lower). Device-to-device transfer stays allowed. | Protects users against losing their phone without letting Google read their notes: Android 9+ encrypts backups with a key protected by the user’s screen lock, which Google cannot decrypt. Devices without a screen lock (or older than Android 9) get no cloud backup. Markdown export remains the user-controlled backup path. For iOS see D-11. |
D-11security, ios | On iOS the notes database (Library/Application Support/Jotry) is excluded from iCloud and computer backups (NSURLIsExcludedFromBackupKey, reapplied on every launch). | iCloud Backup is end-to-end encrypted only with Advanced Data Protection, and iOS cannot limit an app’s backup to encrypted backups, so excluding it is the only way to keep notes out of readable backups. Trade-off: restoring or setting up a new iPhone from a backup does not bring notes along; users move notes with Markdown export/import. |
D-12editor, architecture | The note editor is built on the platform text views: Android EditText (hosted in Compose through AndroidView) and iOS UITextView (hosted in SwiftUI through UIViewRepresentable). Formatting is shown with spans/attributes and checkboxes as inline spans/attachments. Both forward edits to the shared EditorSession, which owns the RichText model, formatting commands and undo/redo. | These views support live formatting while typing, tappable inline checkboxes and embedded content, and keep the system’s selection, keyboard, IME and accessibility behavior. Compose BasicTextField and SwiftUI TextEditor cannot embed inline content, which would block checkboxes now and diagrams or images later. |
D-13editor, roadmap | Diagrams (Mermaid) are out of scope for Release 1. A ```mermaid fence imports, displays and exports as an ordinary code block, unchanged. | Rendering needs a bundled, sandboxed web view per platform plus an edit-source flow, which is an exception to “no syntax in the UI”. D-12 keeps inline embedded blocks possible, so diagrams (and maths) can be added later, possibly as an extension renderer. |
D-14editor, history | The editor saves once 35 characters have changed or 10 seconds after the first unsaved change, whichever comes first, and immediately when the editor closes, the note is left or the app goes to the background (iOS asks for background time to finish). History keeps one version per minute: a save (edit or checklist change) in the same minute as the current version, when that version is also a save or the note’s creation, replaces it with the latest content under a new id. Moves, deletes, restores, imports and copies always add a version. | Frequent saves keep data loss small when the app is killed, while minute resolution keeps the history readable (saving on every pause produced a dozen versions for two minutes of typing). Replacing under a new id keeps stale-state checks such as checklist toggles from the read view working. Refines D-04. |
D-15core, ux | The top level shows notebooks and the notes of a built-in default notebook. Notebooks hold only notes and cannot nest. Every note has exactly one notebook (notebookId is never null); notes created outside a notebook go into the default notebook, which is hidden in the UI and cannot be renamed, archived or deleted. | Keeps the data model uniform (no nullable owner, one move/delete/export path) while the user sees unfiled notes at the top level. Replaces the earlier visible inbox/default-notebook policy. Deleting a notebook moves its notes to the default notebook; it never deletes them. |
D-16ux | Notes can be pinned. Pinned default-notebook notes show at the very top of the top-level list; pinned notes in a notebook show at the top of that notebook only. Pinned notes are ordered by most recent edit. Pinning is not an edit: no revision, no change to updatedAt. | Quick access to the notes in active use, without pin toggles reshuffling the list or polluting history. |
D-17ux, links | A fuzzy note lookup is available from inside a note. It matches titles first, then bodies, ranking title matches above body-only matches. Jumping to a result pushes it onto a per-session note history with no length limit: back returns to the previous note, forward returns to the looked-up note. History is not persisted; the app reopens on the last open note with empty history. | Lets the user cross-reference notes without losing their place or detouring through lists. |
D-18ux | Note lists support press-and-hold multi-select with select all, and move or delete applied to the whole selection. Notebook deletion relies on this: users delete or move the notes first if they want them gone. (Refined by D-40, D-41.) | Makes bulk cleanup possible without deleting notes as a side effect of removing a notebook. |
D-19ux | The pinned list has no size limit and no manual ordering; pinned notes are ordered by recency only (D-16). | Keeps pinning a single toggle and avoids a manual-order model. |
D-20ux, links | The note lookup excludes trashed notes. Back and forward skip notes that were deleted or trashed during the session (D-17). | History and lookup never land on a note the user has deleted. |
D-21ux | Release 1 does not restore a deleted notebook from Trash. Once the undo bar (D-24) has gone, deleting a notebook is final for the notebook; its notes are already safe in the default notebook (D-15). Tapping Undo while the bar is showing does restore the notebook and its notes’ placement. | Restoring a notebook later could not bring its notes back to it without extra bookkeeping, and no data is lost. Undo is immediate, so it can be exact. |
D-22ux | Quick capture opens a blank note with the keyboard up, in the default notebook, with nothing to fill in. A long-press on the app icon offers a Quick capture action (Android app shortcut, iOS Home Screen quick action). An untouched quick-capture note is discarded. | Removes every decision between the thought and the keyboard. |
D-23titles | Note titles are automatic. If the OS offers a local, offline language model (Apple Foundation Models on iOS 26+ with Apple Intelligence; Gemini Nano via ML Kit GenAI/AICore on supported Android devices), it generates the title. Otherwise the title is the first line of the note. Availability is checked at runtime; nothing leaves the device. Title is stored separately from the body, with a titleSource. Resolves the “How is note title represented?” question. | Users should not have to name notes. Many devices will lack a local model, so the first-line fallback is a first-class path, not an error case. |
D-24ux | Destructive actions (delete note, delete selected notes, delete notebook) and moves take effect immediately and show an Undo bar instead of a confirmation dialog. (Refined by D-40.) | Faster and safer than “Are you sure?”, which users click through by habit. |
D-25links, editor | Notes can link to other notes. Typing [[ opens the fuzzy lookup and inserts a link stored by target note ID. The toolbar link button opens a single text field that decides by what is typed: text that looks like a web address becomes a web link, anything else is a note lookup ([[ always means a note). Tapping a link opens the target as a jump in the note history (D-17). (Refined by D-36, D-39.) | Cross-referencing is the main way notes become connected, and it reuses the lookup and history already specified. |
D-26i18n | The apps are localized from one set of files: i18n/en.json defines every string and each <language>.json translates it. The builds generate the platform formats (Android string resources with <plurals>; iOS .strings and .stringsdict in .lproj folders) and never commit them. The first languages besides English (US) are English (UK), Spanish, German and Russian. The shared core holds no translations; the few words it writes itself (CoreStrings: “Untitled”, the “ (copy)“ suffix) are set by each app at startup. Dates use the locale’s own order through date skeletons. Notes, notebook names and Markdown exports are never translated. | One place to translate, so the apps cannot drift apart; each platform still gets its native format and real plural rules (Russian has four), and both OSes offer a per-app language setting. |
D-27encryption, security | Notes are not encrypted at rest by the app: on the device they rely on the OS protection already described (Android file-based encryption, iOS Data Protection, app-private storage, D-10/D-11). Anything that leaves the device in a future cloud sync must be end-to-end encrypted, so the server only ever holds ciphertext. | The OS protects data on the device and end-to-end encryption protects it off the device, without per-platform database drivers, key-loss risk or breaking local search. Options are explored in docs/spikes/cloud-sync-and-e2ee.md, a plan only. |
D-28titles | Regenerating on request lets the user recover from a bad title without typing one, and automatic titles never overwrite a choice the user made. | |
D-29ux, links | Read-only views (the note view and history) show plain-text web addresses as tappable links: text that starts with a scheme (https://, mailto: …) or www.. Bare domains like example.com are not detected, so file names such as notes.md stay text. Detection happens only at display time: the Markdown is unchanged and nothing is added to the note. Code blocks, inline code and existing links are left alone. | Pasted addresses should work without using the link tool (D-25), and keeping the stored text unchanged avoids rewriting notes or creating revisions. |
D-30titles | Refines D-28. While a title is automatic and the on-device model is ready, a title is generated a few seconds after the editor opens, after a save, or right after the user clears the title. It is not limited to one try per editing session. If the model gives nothing, it tries again only once the note has grown by about another 60 characters. With a model ready, an automatic title means a generated one, so Use first line is only offered on devices without a model. The prompt names the title’s language: the note’s language when on-device language detection is confident (NLLanguageRecognizer on iOS, TextClassifier on Android 10+) and the model supports it, otherwise the device’s language. Android generates titles automatically as iOS does. | Generating only once per session, only after typing, missed notes opened to read and titles the user cleared. Asking for “the note’s own language” let the model guess, and notes made of lists, names or commands came back with titles in other languages. |
D-31branding | The app icon’s mark is named | The header should match the icon people tap to open the app; a name saves describing the shape. “Default” says what that place is: where notes go when no notebook is chosen. |
D-32branding | Refines D-31. The app icon’s mark is named Jotmark, not Radix. Nothing else in D-31 changes. | A plain name that says what it is: the Jotry mark. |
D-33cloud, sync | Future multi-device cloud sync (Phase 4) runs on Jotry’s own server (the Ktor server/ module), end-to-end encrypted so the server only holds ciphertext. | Platform stores cannot sync an iPhone with an Android phone, and a managed backend adds a third party that sees sync metadata. Running our own server costs more to build and operate. Design: docs/spikes/cloud-sync-and-e2ee.md, a plan only. |
D-34billing, export | Markdown export and folder sync are never paid features. If Jotry charges for anything, it is hosted cloud sync (D-33). | Folder sync over iCloud Drive or Syncthing already gives free multi-device sync, so charging for it protects little revenue and breaks the promise that users can always take their notes elsewhere. People pay for convenience, cross-platform sync and history, not for moving files. Options: docs/spikes/monetization-and-sync-tiers.md. |
D-35sync, history | Folder sync exchanges current notes only; revision history stays in the app (as doc 03 already describes). Hosted cloud sync (D-33) transports the full revision history to every device. | This is the real difference between the two: files have no place for Jotry’s history, and a sync service that keeps every version on every device is worth paying for. |
D-36links, export | Refines D-25 for exported and synced files. A note link (jotry://note/<noteId> inside the app) is written to Markdown files as a standard relative link to the target’s file, for example [Ideas](../Personal/Ideas.md). Import turns relative links to imported files back into note links. A link whose target isn’t among the written files keeps its label as plain text. | Relative links work in other Markdown tools (Obsidian, VS Code, GitHub, iA Writer); jotry:// addresses work nowhere else. Export and folder sync share the same translator. Design: docs/spikes/folder-sync.md, a plan only. |
D-37history | Revisions keep storing each version as a complete note (full Markdown snapshot), not as forward or reverse diffs. Version history shows what changed in each version compared with the version before it. The diff is worked out when the user views it and is never stored. Each change appears with some unchanged text before and after it. Deleted text has a mild red background; added text has a mild green background. Formatting changes are not shown as changes. A version whose only changes are formatting says so in the UI. Checking or unchecking a task is a change (☐ becomes ☑), since it is an edit (D-08). A renamed title is not a change. Deleted text is also struck through, so color is not the only cue, and screen readers say “added” and “removed”. (Refined by D-38, D-43.) | Full snapshots keep preview, restore and copy to a single lookup, let one damaged revision affect only itself, and match the revision records that sync will transfer (D-04, D-14). Notes are small, so the space saved by diffs does not justify a delta chain. Snapshots allow any two versions to be compared, so highlighting needs no stored diffs. Formatting is hidden from the diff because the UI never shows Markdown syntax. If storage grows, the first remedy is to store an unchanged body once (moves and deletes currently copy it), not delta chains. |
D-38history, ux | Refines D-37. The unchanged text around a change runs to the start and end of its line, but no further than about 100 characters on either side. The first version of a note (“Created”) has nothing before it, so its whole text shows as added (green). | A line is the natural unit of context in a note, and the 100-character cap keeps long paragraphs from burying the change. Showing the first version as all added keeps every version’s view consistent: it always shows what that version brought in. |
D-39links | Refines D-25. A note link shows the target note’s current title, so renaming the target updates every link to it. When the user chooses the words (linking selected text, or typing inside a link’s label), the link keeps those words. The link address records the choice: jotry://note/<noteId> follows the title, jotry://note/<noteId>?label=custom keeps its label. The stored label is updated the next time the linking note is saved; a rename never creates revisions in other notes. Import treats a label equal to the target’s title as following the title. Resolves the open question “What text do note links display?”. | Links that keep a stale title after a rename look broken, while words the user picked are part of what they wrote. Updating labels when the note is shown avoids rewriting other notes’ history for a rename. |
D-40ux | The Trash screen has a More (three dots) menu with Select and Empty trash. Notes in the trash can be selected (press and hold, or Select) and restored together with Restore selected in the menu or the selection bar’s Restore button; restoring several shows an Undo bar. Empty trash follows D-24: no confirmation dialog, the notes disappear at once with an Undo bar, and they are deleted forever, with their whole history, only once the bar has gone. If the app is closed before then, the notes stay in the trash. Refines D-18 and D-24; resolves the open question on confirming permanent deletion. | Permanent deletion is the one action Undo can’t reverse later, so deferring it until the bar goes keeps “no Are you sure?” safe. Restoring many notes one at a time was tedious. |
D-41ux | Search results can be selected like a note list (D-18): press and hold a result, or Select in the Search screen’s More menu, then Move or Delete the selection from the selection bar. Both act at once with an Undo bar (D-24). The move dialog marks a notebook as current only when every selected note is in it. Changing the search keeps only the selected notes that are still in the results. Refines D-18. | Search is how notes are found across notebooks, so it is the natural place to gather related notes into one notebook or clear out old ones, without opening each notebook. |
D-42export, format | Markdown export (a one-time copy to a folder Jotry doesn’t track afterwards) writes each note as <Notebook name>/<Note title>.md; notes in the default notebook (D-15) sit at the top of the export, with no inbox folder. Names keep the title and notebook name as typed: only characters unsafe on common file systems (/ \ : * ? " < > | and control characters) become -, leading dots and trailing dots and spaces are dropped, names are written in Unicode NFC (composed accents) and capped at 120 UTF-8 bytes, and an empty name becomes “Untitled”. Import compares file paths, link targets and folder names in NFC too, because some file systems and sync tools store accents decomposed. Repeated names in a folder, ignoring case, get “ 2“, “ 3“ … in creation order. Export always writes YAML front matter: note_id, notebook_id, notebook (left out for the default notebook), title, created_at, updated_at, and pinned: true for pinned notes. Import matches notes by note_id, restores pins, and treats an imported title as set by hand, so the on-device model never replaces it (D-28); re-importing a file whose title hasn’t changed keeps the note’s title source. Import still reads exports in the earlier <notebook-slug>/<note-slug>--<noteId>.md layout. Replaces the layout recommended in doc 02 (“Markdown file representation”); the export format version becomes 2. Resolves the open question on Jotry metadata for exported files. Folder sync (a later phase) keeps a folder up to date and is a separate question: there, identity is planned to live in a note-to-file table in the app (docs/spikes/folder-sync.md, section 2), not in the files. | An export folder has no table in the app to recognize its files later, so the front matter is what lets a re-import update notes instead of duplicating them. File names from titles look native in Finder, Files and other Markdown apps, where ID suffixes and slugs looked machine-made, and identity no longer depends on the name. A title that came from outside the app is the user’s text, so a model shouldn’t rewrite it. |
D-43history | Version history doesn’t show moving a note to another notebook, moving it to the trash, or restoring it from the trash. These are still stored as revisions (D-37) but are left out of the history list. The newest version shown is the current one, and each version is still compared with the text before it. Restoring an earlier version changes only the text and title; the note stays in its notebook. Refines D-37. | These versions don’t change the text, so opening one showed nothing new and restoring it did nothing useful. History is about what was written; where a note lives is visible from its notebook. |
D-44cloud | Hosted cloud sync (D-33) will run on a self-hosted Supabase stack, container virtual machines on the owner’s Mac, | D-33 rejected a managed backend because it adds a third party that sees sync metadata. A Supabase stack that runs on hardware the owner controls has no third party, and it gives Postgres, authentication and realtime without writing a server. container has no built-in Compose, the phones must reach the Mac, and the Mac must be on.docs/spikes/cloud-sync-and-e2ee.md section 9, a plan only. |
D-45cloud, sync | Hosted sync is for the owner’s own devices for now, not yet a product service. Devices join an account-free sync group (a QR code from a device already in the group, and a recovery key as the fallback), with no email or password. The server runs on the owner’s backend infrastructure behind HTTPS. The Apple container part of D-44 no longer applies. Whether the backend is the self-hosted Supabase of D-44 or an own server (D-33) is being re-evaluated and is not decided. Refines D-33 and D-44. (Refined by D-46, D-48, D-52.) | The owner already has backend infrastructure with HTTPS, so the Mac and container are not needed and iOS gets the HTTPS it requires. Account-free sync groups keep the README promise “no account” and suit a personal service. Backend options and the framework comparison: docs/spikes/cloud-sync-and-e2ee.md section 10, a plan only. |
D-46cloud | The sync server gets its own Postgres instance on the backend infrastructure; it does not share a database server with the other services there. The server and its Postgres together stay within a few hundred MB of RAM. Refines D-45. | The infrastructure runs many other services; a dedicated instance keeps upgrades, backups and failures of the sync data separate from them. |
D-47encryption | Sync encryption uses libsodium (the com.ionspin.kotlin multiplatform bindings) with XChaCha20-Poly1305. Each note has a random data key that is wrapped under the group’s root key; the note’s records are sealed with that data key. Supersedes the per-record HKDF keys and AES-256-GCM of the first :crypto prototype. Refines D-27. | Owner’s choice. Rotating the root key only rewraps one small key per note, per-note keys allow sharing a single note later, and XChaCha20’s 192-bit random nonces need no care. libsodium also brings X25519 and Ed25519 on every platform, independent of the Android API level. |
D-48encryption, sync | Sync key management: devices join by QR code from a device already in the group (device-to-device enrollment), and a 24-word recovery key is the break-glass recovery. The recovery key is shown once when sync is turned on and wraps a copy of the root key kept on the server. Losing every device and the recovery key means the synced data cannot be recovered, and the app says so. No password-derived key. Refines D-45. (Refined by D-55, D-57.) | Owner’s choice. QR enrollment needs no secret to remember in the normal case; the recovery key covers losing all devices without giving the server anything it could guess. |
D-49sync, ux | When sync finds a note deleted on one device and edited on another, it asks the user during sync which to keep (keep the edited note, or delete it), instead of applying a fixed rule. (Refined by D-73: where the prompt appears.) | Owner’s choice. Either automatic rule loses something the user meant: the edit, or the delete. |
D-50encryption, cloud | Synced records are not padded to hide note lengths. Encrypting the contents is enough; the server may see record sizes, timestamps and which device wrote them. | Owner’s choice for a personal server. Padding costs storage and bandwidth for a leak that doesn’t matter here. |
D-51encryption, desktop | On desktop, the root key and device signing key are stored in the OS key store: macOS Keychain, Windows DPAPI, Linux Secret Service. Where none is available (for example Linux without a Secret Service daemon), the app asks for a passphrase that protects the keys. (Refined by D-57.) | Owner’s choice. Matches Keystore / Keychain on the phones, with a fallback that still works on minimal Linux setups. |
D-52billing, cloud | Paid entitlement belongs to the sync group, not to a device or a store account. A group can hold several entitlements at once, from different stores (for example an Apple subscription and a Google Play one), and it is entitled for as long as any of them is active: the latest expiry wins. Switching phones between iOS and Android needs no new purchase, because the new device joins the group by QR (D-48). Applies when sync is offered to other users; until then D-45 (the owner’s own devices) stands. Refines D-33 and D-45. (Refined by D-54, D-56, D-58.) | Without Jotry accounts the group is the only identity the server has. Per-group entitlement lets one purchase serve every device and every platform, and a store switch is just a second source for the same group. Design: docs/spikes/sync-system-design.md and docs/spikes/monetization-and-sync-tiers.md, plans only. |
D-53billing | Hosted sync is sold as a subscription bought in the app through the stores’ own billing: App Store and Mac App Store, Google Play, and the Microsoft Store if it proves workable. The app is free to download and everything local stays free (D-34). There is no free sync tier: the purchase creates the sync group. Adding a device to an existing group is free. There is no web checkout or license key for now. Refines D-34. | A recurring server cost fits a subscription. Making the purchase the point where a group is created removes mass free-group abuse and the need to bill anyone outside the stores. Store rules require in-app purchase for unlocking features; Apple’s rule on multiplatform services (3.1.3(b)) lets access bought elsewhere work only if the same purchase is also offered in the iOS app, which this does. |
D-54billing, cloud | A store purchase is bound to its sync group through the store’s account token (Apple appAccountToken set from the group id; the Google Play equivalent to be confirmed). The server verifies signed transactions (Apple) or purchase tokens (Google), follows the stores’ server notifications for renewals, expiry and refunds, and maps each store transaction to exactly one group. Moving a transaction to another group is allowed with a cooldown. Refines D-52. | The store’s own signature then says which group a purchase is for, so a leaked receipt cannot be claimed by another group and the server needs no user identity. Rebinding covers a new phone with the same store account after the group was lost. |
D-55encryption | The 24-word recovery key (D-48) derives three things: the group’s lookup id, the key that unwraps the root key copy held on the server, and a recovery signing key whose public half is registered when the group is created. A device holding only the recovery key proves possession by signing the server’s challenge, registers its own new device key, unwraps the root key and pulls everything. The recovery key is shown once and the user re-enters part of it before sync is turned on. Refines D-48. (Refined by D-66.) (Refined by D-75: the server’s copy of the root key is sealed to a key derived from the recovery key, not wrapped under a symmetric one.) | The recovery key has to work with no device left and no account, and the server must never be able to guess it. Checking the user saved it avoids the most common way to lose data. |
D-56billing, cloud | A group whose entitlement has lapsed is read-only: devices can still pull and read, but not push. The server keeps it for 1 year after the last entitlement ended, then deletes it. Renewing or restoring the purchase (same store account) within that year reattaches it. Refines D-52. | A lapsed subscription should never lock people out of their own notes, and a year gives time to notice and recover. A fixed limit bounds what the service stores for people who left. |
D-57encryption, ux | When sync is turned on, the user chooses whether to back the device key up in the OS keychain (iCloud Keychain, Android Backup) so a restored phone rejoins without the recovery key. The recovery key is always required either way. Refines D-48 and D-51. | It makes the common “new phone” case easy for people who trust their platform backup, and leaves the choice with the user. |
D-58billing, cloud | Entitlement checks sit behind a pluggable verifier on the server (development, Apple, Google, Microsoft). Testing and promotional entitlements can exist in production alongside paid ones; how they are issued and managed is decided later. The development verifier cannot be enabled in a production build. Store developer accounts are needed only for the billing step, not for building sync. Refines D-52. (Refined by D-70: what a production build is, and how the development verifier stays out of it. Refined by D-72: promo entitlements are promo codes stored on the server.) | Lets sync, enrollment and recovery be built and tested before any store account exists, keeps a way to grant access to testers, and stops a test shortcut from becoming a hole. |
D-59roadmap, android, ios | The minimum supported versions are iOS 17 and Android 9 (API 28). Newer features are used only behind runtime checks (for example Apple Foundation Models on iOS 26 and later, and TextClassifier language detection on Android 10 and later; D-23, D-30). | Owner’s choice. iOS 17 keeps the Observation framework (@Observable) and symbol effects the iOS app already uses; iOS 16 would mean rewriting that code. API 28 gives java.time and ML Kit GenAI without workarounds. Both cover current devices comfortably for a new app. |
D-60architecture, cloud | The sync server’s business logic lives in a framework-free module (server/service) behind interfaces it declares itself (record store, clock, store-receipt verifier, health probe, and so on). The web framework (Ktor first), the Postgres store and the Apple, Google and Microsoft verifiers (D-58) are adapters in other modules that implement or call those interfaces. Adapters hold no decisions, and server/service has no dependency on a web framework or database driver, which the Gradle module boundary enforces. Swapping the framework means writing a new adapter only. Refines D-45. | Keeps the framework choice (Ktor, http4k, Quarkus) cheap to change, as the framework comparison left it open, and lets the business rules be tested with the JVM alone. Layout and rules: server/README.md. |
D-61architecture, cloud | The sync server’s libraries: Koin for dependency injection, Exposed in its DSL style (not the DAO style) for database access, Flyway for schema migrations, HikariCP for the connection pool, and Postgres. Migrations are plain SQL files (server/postgres/src/main/resources/db/migration), applied when the server starts; Exposed never creates or alters tables. Koin is used only in the composition root (server/src); server/service and the adapters take their dependencies as constructor parameters and do not depend on Koin. Database tests run against a real Postgres (Testcontainers, or an existing one). Refines D-60. | Owner’s choice. Checked against what the sync server needs (sync-system-design.md): the conditional put (UPDATE … WHERE), the per-group counter (UPDATE … RETURNING), the cursor-based change feed, upserts, bytea columns and row locks (SELECT … FOR UPDATE) are all in Exposed’s DSL, and raw SQL is there for anything that is not, so nothing called for a different library. Migrations in SQL keep the schema reviewable and independent of the Kotlin code; keeping Koin out of the inner modules keeps D-60’s boundary. |
D-62cloud, security | Until device auth exists (epic 4), any sync endpoint without auth is served only when the server is started with SYNC_PROTOTYPE=true. It is off by default; the development compose file turns it on, and the test compose file and production never do. Epic 4 replaces it with device tokens and removes the switch. (Refined by D-68: the switch stays, for group creation only. Retired by D-70.) | Owner’s choice. Lets the first endpoints be tried from a phone on the local network (K1) without an unauthenticated route ever reaching a server that testers or anyone else can reach. |
D-63cloud, sync | The sync API’s request and response bodies are CBOR (RFC 8949, content type application/cbor), encoded with kotlinx-serialization-cbor. The request and response classes live in one shared Kotlin Multiplatform module that the server and the clients’ sync engine both use, so they are the contract. Fields carry integer labels (@CborLabel) rather than names, binary values such as envelopes and keys are CBOR byte strings (never base64), and readers ignore unknown keys, so adding a field never breaks an older client or server. A label is never reused once removed. Operational endpoints such as /health stay JSON. Resolves the open question on the wire format. | Owner’s choice. Records are mostly ciphertext, so a binary format avoids base64’s third more bytes, and the format’s own overhead is tiny either way. With Kotlin on the server and in the shared sync engine, one set of @Serializable classes needs no schema file or code generation, and the compiler catches mismatches. CBOR also has a defined deterministic encoding (RFC 8949 section 4.2) if a payload ever needs signing. Protocol Buffers was the alternative: a language-neutral schema with built-in compatibility rules, worth it only once a client or server is not Kotlin. |
D-64encryption, sync | Removing a device also starts a new data key for every note, not only a new root key. A note’s key record is a list of keys numbered from 1. Revisions written after the rotation are sealed with the newest key; older revisions keep the key that sealed them, so they stay write-once and are never re-encrypted. Every record envelope names the key that sealed it, which changes the :crypto formats to version 2 (record envelope and wrapped note key). Refines D-47. (Refined by D-75: how a rotation is carried out.) | With one fixed key per note, a removed device that had already unwrapped note keys could still read future revisions of those notes if it got the ciphertext some other way, so protection then rested on the server’s access control alone. A new key per rotation closes that. What stays true: the removed device can still read whatever it copied before, and notes it never opened stay protected by the new root key. Version 1 never shipped, so there is nothing to migrate. |
D-65cloud, sync | The sync server stores records in one table, one per group and record id (a UUID made by the client). A record’s kind is notebook, note-key, note or revision: a notebook (its name, order and archiving), a note’s key list (D-64), a note’s current state (current revision, notebook, pinned, trashed) or one revision snapshot. Every kind but notebook names its note; a notebook’s own key is a note-key record with the notebook’s id as its note id. The server never reads an envelope. Every write takes the next number from the group’s counter row, under a lock held until the transaction commits, and stores it as the record’s seq, so changes become visible in seq order. Conditional put: a write carries the seq it last saw (0 to create) and is refused with the current seq (HTTP 409) when they differ; there is no separate version column. Deletes are tombstones: the same record id with no envelope, delivered through the change feed. A deleted notebook, note-key or note record may be written again (for example undoing a notebook delete); a revision is written once and afterwards may only be deleted, never changed or revived. A record never changes its kind or note. Change feed: the records of a group after a cursor (a seq), in seq order, at most 500 per page, with the next cursor. Conflict handling is on the device, never on the server: merging two heads, the delete-versus-edit prompt (D-49), and a note whose notebook was deleted, which moves to the default notebook as D-15 does (a notebook not yet received is not treated as deleted). A push needs an entitled group, a pull a group that has not expired (D-56). Resolves the open question on the record model; refines the sketch in section 7 of sync-system-design.md. | Owner’s choice, after review. One table keeps the change feed a single indexed query and gives every kind the same put; the server cannot tell the kinds apart beyond these rules anyway. seq already changes on every write, so it doubles as the version a conditional put compares. A separate note-key record lets root key rotation rewrite only keys, never contending with edits. Write-once revisions keep history immutable on the server as on the device (D-37). |
D-66encryption | The 24-word recovery key (D-48, D-55) is BIP-39 English: 256 random bits plus an 8-bit checksum (the first byte of their SHA-256), 11 bits per word, so 24 words. The checksum catches a mistyped or swapped word before anything is derived. Typing ignores case, spacing, punctuation and numbering, and a word may be shortened to its first four or more letters, which the list makes unambiguous. The three values of D-55 come from the 256 bits directly with HKDF-SHA-256 (empty salt), each under its own label (jotry/v1/recovery/lookup-id, .../wrapping-key, .../signing-key); BIP-39’s PBKDF2 seed is not used. The lookup id is 32 bytes and the server’s copy of the root key is a 78-byte XChaCha20-Poly1305 wrap bound to the group id. Refines D-55; resolves the open question on the recovery key encoding in section 9 of sync-system-design.md. | Owner’s choice of word list. BIP-39 English is a well-known, carefully chosen list with published test vectors, and its checksum turns a typo into a clear error instead of a wrong key. The bits are already uniformly random, so a slow password hash would add nothing. |
D-67security, cloud | A device gets a session by signing a single-use challenge (60 seconds) with its Ed25519 key, and receives an opaque random token: 256 bits, valid 15 minutes, sent as Authorization: Bearer. The server stores only the token’s SHA-256, with its group, device and expiry in Postgres, and checks a request with one lookup that also confirms the device is not removed. Removing a device deletes its tokens, so it loses access at once. Signed tokens (JWT) are not used. Refines the authentication part of D-45. | Owner’s choice, after review. Every sync request already reads the shared Postgres, so a token lookup adds almost nothing and any number of server containers can serve any token. A JWT stays valid until it expires, which would let a removed device keep reading for up to 15 minutes unless each request also checked the device in the database, which removes its advantage. It would also need a signing key to protect and rotate. Reconsider if another service must verify tokens without the database, or if lookups become a measured bottleneck (a short cache comes first). |
D-68security, cloud | Refines D-62. With device auth (D-67) every sync route but one needs a bearer token, so SYNC_PROTOTYPE=true now guards only creating a group without a purchase, which is still the one unauthenticated way to get a group (and a development entitlement) until the store verifiers exist (epic 5, D-58). Creating a group also registers its first device: the request carries the device id and signing key and a signature, with that key, of the create-group statement, so the server never holds a key the device cannot use. Devices sign statements built the same way on the server and in :syncApi (Statements and SyncStatements, each a label and 4-byte length-prefixed parts), and the QR enrollment and recovery endpoints are always served: they need no purchase and are protected by what they check (a member’s signature, or the recovery key’s). | Owner’s choice. Keeps the one route that could create free groups off any server testers can reach, while letting the rest be built and tried against a real server. (The switch is retired by D-70.) |
D-69security, cloud | The endpoints anyone on the internet can call are rate limited with token buckets, per client address: auth challenge and token 30 a minute; starting a QR enrollment 10 an hour; polling for an enrollment result 60 a minute; recovery challenge and enroll 10 an hour per address, and also 10 an hour per recovery lookup id so many addresses cannot hammer one group; creating a prototype group 10 an hour. A limited call gets HTTP 429 with a Retry-After header and the error code rate-limited. Signed-in traffic (records, changes, devices) is not limited here; per-group quotas are a launch item. Counts are kept in memory in each server container, so with several containers the effective limit is the limit times the containers, and the table holds at most 100,000 keys (idle ones are dropped, and new keys are refused while it is full). The client address is the connection’s; behind a reverse proxy, TRUST_FORWARDED_FOR=true takes the last X-Forwarded-For entry, which the proxy appends, so a caller cannot choose its own bucket; without a proxy it must stay off. A maintenance job runs in every container every 5 minutes (MAINTENANCE_INTERVAL_SECONDS, 0 turns it off): it deletes expired challenges, tokens and enrollments, including an approved enrollment that was never collected, which holds a wrapped root key, and drops idle rate-limit buckets. Expired rows are refused when used anyway; the job only frees them. | Owner’s choice. These endpoints need no sign-in, so they are the ones to abuse: filling the tables with challenges and enrollments, or guessing at recovery. A per-container counter stops that without putting the limiter on the database, and exactness across containers does not matter for abuse limits. Trusting X-Forwarded-For blindly would let anyone pick a fresh bucket per request, so it is off by default and only the proxy-appended entry counts. |
D-70billing, cloud, security | Refines D-58 and retires the SYNC_PROTOTYPE switch of D-62 and D-68. Creating a group always needs a purchase that a verifier accepts: the request names the purchase’s source and carries the store’s proof, and the server checks it with the verifier for that source (one per source; a source without one is refused). The purchase’s account token must be the new group’s id (D-54), the purchase must not have ended, and it must not belong to another group already. The development verifier accepts a proof that is just the group id, as a one-year entitlement with the dev source. It lives in its own module, server/dev, which only a development build contains: ./gradlew :server:run, the tests, and installDist -Pdevelopment (used for compose.dev.yaml). An ordinary installDist, which the test server and production run, does not contain the class, so it refuses development purchases; a build check fails if the module ever reaches an ordinary build’s runtime classpath, and a development build logs a warning at start. The server finds verifiers at start through java.util.ServiceLoader, so leaving the module out is all it takes. The development verifier has no switch at run time. | Owner’s choice. D-58 asks for a guard in the build, not in configuration: a class that is not in the artifact cannot be turned on by a mistaken environment variable. With every group created through a verifier, there is one creation path to test, and the store verifiers plug in beside the development one. |
D-71billing, cloud | A signed-in device can hand over a purchase for its own group (POST /v1/groups/{groupId}/purchases): restoring it, buying in another store, or a renewal the app saw first. It is checked by the verifier for its source, and its account token must be the group id (D-54). A transaction new to the server adds an entitlement row; one the group already holds (same source and store transaction id) updates that row instead of adding one: the expiry only moves later, and a revocation (refund), once recorded, keeps its earliest time, so a stale proof can neither shorten an entitlement nor undo a refund. A transaction another group holds is refused (409 purchase-used). A lapsed group can do this while it still exists, which is how it reattaches (D-56); an expired one cannot sign in. Refines D-52 and D-56. | Owner’s choice. Apple keeps a subscription’s original transaction id across renewals, so one row per subscription stays the true record, and the group’s access is still worked out from its rows (D-56). Taking the later expiry and the earliest refund makes the merge safe to repeat and to run from several devices at once. |
D-72billing, cloud | Promo and tester entitlements (D-58) are promo codes stored on the server, until the stores’ own offer codes are looked at after launch. Whoever runs the server creates codes with a command-line tool in the server’s distribution (bin/promo create --days N [--uses N] [--count N] [--redeem-by DATE] [--note TEXT], plus list and revoke), which writes to the server’s Postgres. A code looks like JOTRY-7K2MQ-X9P4R (50 random bits, Crockford base 32, forgiving of case, spacing and lookalike letters), and the server stores only its SHA-256, with its length in days, how many groups may redeem it, an optional deadline, a note and whether it was revoked. The app sends a code as a purchase from the promo source, both when creating a group and through the purchases route (D-71); it is checked by a promo verifier that every build has. A code names no group, so verifiers are now told which group a purchase is for and the promo verifier redeems the code for that group; its entitlement runs the code’s days from that first redemption, and its transaction id is the code and group together, so one code can serve several groups (each with its own row) while a group that sends the same code again gets no extra time. Each redemption is recorded; revoking a code stops new redemptions and leaves earlier ones alone. Refines D-58. | Owner’s choice. Codes short enough to type, revocable before use, shareable among testers and auditable, with no store account needed; the cost is a table, a tool, and production database access to create codes. Store offer codes need the store setup of epic 8 and convert to paid subscriptions, which suits marketing more than testers. |
D-73sync, ux | Where the delete-versus-edit prompt of D-49 appears. When sync runs while the app is on screen, the prompt appears on screen. When sync runs in the background, the app sends a notification instead, and tapping it opens the app on the prompt. Until the user answers, the note stays, with the edit, marked as needing a decision, and its delete is not sent. Refines D-49; resolves “where the prompt appears, and what happens if sync runs in the background” in section 9 of sync-system-design.md. | Owner’s choice. A conflict found in the background still reaches the user, and nothing is decided for them while they are away. |
D-74encryption, sync | Every device has a long-term X25519 encryption key beside its Ed25519 signing key. It is made once, kept in the Android Keystore or iOS Keychain with the signing key, and registered with the server when the group is created, when a device is enrolled by QR (the key in the QR code is this key, not a throwaway) and when a device is added by recovery. The statements that are signed to create a group, add a device and recover (create-group, add-device, recover) now include it, so a member approving a device vouches for the exact key the root key will be sealed to, and the server cannot swap it. The root key is sealed to it with the 110-byte wrap of DeviceEnrollment: at enrollment, and again for each remaining device after a rotation (D-75). The server lists each device’s key. Resolves the open question on how a rotated root key reaches the other devices. Refines D-48 and D-64. Known limit: a rotating device takes the list of devices and their keys from the server, as an enrolling device takes the approver’s wrap from it (sealed with an ephemeral key, not signed), so a malicious server could add an entry of its own and be sent the new root key. The server cannot add a member without a member’s signature, but clients do not check the approvals. Closing this means storing each approval and having clients verify the chain of approvals from a device they trust (an open question). | Owner’s choice. Separate keys for signing and for encryption is the usual practice, and a key made for encryption is cheap next to deriving one from the signing key, which would make one key serve two purposes. Putting it in the signed statements costs a few bytes and makes the approval cover what actually matters. The gap in the limit above is the same one enrollment already has, and the design’s threat table should say so rather than promise more. |
D-75encryption, sync, cloud | How the root key is rotated after a device is removed (D-64). The server keeps the current root key id, the date of the last rotation, and for each device the newest root key id it was given. POST /v1/groups/{groupId}/root-key moves the id on by one and replaces the recovery copy in one conditional step; it is refused (409 root-key-conflict) unless the new id is the current one plus one, so of two devices rotating at once only one wins. PUT /v1/groups/{groupId}/devices/{deviceId}/root-key stores the current root key sealed to one device (D-74), for it to collect with GET /v1/groups/{groupId}/root-key; that call also says whether a rotation is due (a device was removed after the last rotation, or after creation if there never was one) and lists the active devices not yet given the current key. The recovery copy is sealed to an X25519 key derived from the 24 words (HKDF label jotry/v1/recovery/encryption-key; the server stores its public half when the group is created), instead of wrapped under a key derived from them, so any device can replace the copy during a rotation and only the words can open it; this changes the copy from 78 to 110 bytes and drops the derived wrapping key of D-55 and D-66. On the device, the first remaining device to sync after a removal rotates, without being asked: it makes root key n+1, keeps it, has the server move on, gives every note and notebook it holds keys for one more note key and wraps all its keys under the new root key (the changed key lists go out with the sync), and seals the new root key to each device that does not have it. A device that finds the group on a newer root key opens its copy; if none was sent yet it waits and retries, and any member that holds the key sends it to a device that is behind. Devices keep every root key they have held, so key lists written before a rotation still open, and a device that was offline across several rotations catches up. A rotation interrupted at any point is settled by the next sync (the new key is kept in the vault and the rotation noted in the database before the server is told; a key the server never heard of is dropped, one it accepted is finished). Rotating needs an entitled group. Groups made before this decision have devices with no encryption key and a recovery copy in the old format; nothing was released, so they are not migrated. Refines D-64, D-55 and D-66. | Owner’s choice, after review. Making the recovery copy a sealed box is what lets a rotation finish without the words; the alternative, asking for them at every removal, would leave recovery on the old key whenever the user did not have them. Having the server compare ids in one conditional update gives a single winner without a lock, and tracking what each device was given lets any member repair a device that was enrolled mid-rotation or slept through one, instead of relying on the device that happened to rotate. Doing the rotation on the next sync keeps removing a lost phone a single quick action and makes it automatic, which D-64 relies on. |
D-76desktop, architecture, ux | The desktop build starts, ahead of the web client (refines D-07). It is built for Linux, macOS and Windows together. The UI is Compose Multiplatform for desktop in app/desktopUi, on the shared NotesCore, inside a Nucleus window (app/desktopApp), with a plain Compose window for development (app/desktopDev). desktopUi stays free of Nucleus, so the shell can fall back to the official Compose plugin. This applies D-01 with Compose as the native UI on desktop. The editor is our own BasicTextField adapter over the shared EditorSession (extends D-12); no text-editor library. The screens follow the Studio Stationery Graphite look of docs/design/ui-brief.md and the first desktop designs in the Jotry project in Google Stitch (notebooks sidebar, notes list with pinned notes, editor, history), but only for features the phones have. The Stitch ideas that the apps lack (dashboard statistics, embedded images, a repository footer, a revision “ledger” with snapshot sizes) are not built. Open: native image or JVM for release builds, whether Android screens move to a shared Compose module, desktop database encryption, minimum OS versions. | Owner’s choice to start (2026-10-11), and to build for all three systems (2026-10-07). The spike (desktop-app.md) showed the core, the editor session and a SQLite file working on all three systems and Compose UI tests running in-process; the ComposeTextEditor spike found its second document model not worth the dependency. |
D-77sync, encryption | What the QR code for joining a group carries (design section 5). The new device shows JOTRY1: followed by base64url, without padding, of a CBOR map with integer labels as on the wire (D-63): 1 the server URL, 2 the enrollment id, 3 the device id (both as their 16 bytes), 4 its X25519 encryption key and 5 its Ed25519 signing key (D-74). JOTRY1 names the format and its version; a later version gets a new prefix. The member that scans it refuses a code for another server, and checks the device id and both keys against the server’s copy of the request before it seals the root key and signs the add-device statement. On Android, Google Play services’ code scanner reads the code (no camera permission in the app) and qrcode-kotlin draws it; on iOS, VisionKit and Core Image. Refines D-48 and D-74. | Owner’s choice. Adding the device id to the fields the design listed lets the member check everything the add-device statement signs against what it saw on the screen, for 16 bytes; the code stays near 190 characters. CBOR keeps one encoding for everything Jotry exchanges. The Play services scanner needs no camera code or permission in the app. |
D-78sync, encryption, desktop | Desktop sync starts on macOS, with keys in the login Keychain. The desktop app has the phones’ sync screens (turn on with a promo code and the 24 recovery words, join by QR code, status and Sync now, turn off on this device), syncing when the window opens or comes to the front and on Sync now. Its keys go to a SecretStore over the macOS Keychain, reached through Apple’s security tool, with each secret passed on standard input (security -i) as hex, never in the arguments. On Windows and Linux the Sync screen says sync comes later, because keys held in memory would be lost at each start; the key stores of D-51 (DPAPI, Secret Service, passphrase) are still to build. A desktop shows its join code as a picture and as text to copy, and approves another device by a code pasted as text, since it has no scanner; a phone cannot show its code as text yet. Refines D-51 and D-77. | Owner’s choice (2026-10-11): macOS first, as epic 10 says. The security tool needs no native binding, no new dependency and works in a native image. Its limit: items made this way list /usr/bin/security as trusted, so another program of the same user that runs security find-generic-password reads them without a prompt; a native binding with the app’s own access list closes that, and belongs with signing the app. |
D-79sync, ux | When the apps sync. While sync is on, the apps sync when they open or come back to the front, on Sync now, 5 seconds after the last edit made on the device (a sync’s own changes don’t count), and in the background every 30 minutes: Android with WorkManager while there is a network, iOS by asking for a background app refresh 30 minutes on, which iOS runs when it sees fit. The desktop app has no background sync. The notification of D-73 needs permission, which the app asks for once, on the Sync screen while sync is on; asking again is left to the system’s settings. Refines D-73. | Owner’s choice (2026-10-12). Thirty minutes keeps the devices close without waking the phone often, and Android won’t schedule work more often than every 15 minutes anyway. Asking on the Sync screen ties the request to the feature that needs it, rather than to the app’s first start. |
D-80sync, encryption, ux | Device names, sealed, and when each device last synced. Each device has a name in its group’s devices list, sealed so the server can’t read it: XChaCha20-Poly1305 under a key derived from the group’s root key (HKDF-SHA-256, info jotry/v1/device-name), with the group and device ids in the associated data (DeviceNames in :crypto). A device names itself with a default when it has none (Android: the name in About phone, else the model; iOS: what iOS gives apps, usually just “iPhone”; desktop: the computer’s network name), and any member can rename any device (PUT /v1/groups/{groupId}/devices/{deviceId}/name, at most 512 bytes). After a rotation each device seals its own name again under the new key on its next sync, so a device that joined since can read it; until then the list shows “Another device”. The server also records when each device last got a token, roughly when it last synced, and the list shows it. Refines D-75 and D-74. | Owner’s choice (2026-10-12), after “Another device” could not tell three devices apart. Sealing keeps what people call their devices off the server; the time a device last signed in is something the server sees anyway, and it is what tells a lost or unused device apart. |
D-09: Local database: SQLite via SQLDelight
Status: Accepted (2026-09-28). Resolves the Phase 1 question “Which local database and SQL abstraction?”
Decision. The shared core (app/sharedLogic) stores notebooks, notes, revisions and local search data in one SQLite database. It accesses that database through SQLDelight 2.x. The schema and queries are plain SQL in .sq files, and SQLDelight generates the type-safe Kotlin. Each platform uses its SQLDelight driver: the Android driver on Android, the native driver on iOS, and the JDBC driver on the JVM (tests only). No database type is exposed outside the shared core. Both native apps use only the NotesCore use cases, so neither Kotlin nor Swift UI code depends on SQLDelight.
Alternatives considered.
| Option | Why not chosen |
|---|---|
| Room KMP (AndroidX) | A credible alternative, officially backed and familiar to Android developers. Rejected because its main advantage is the DAO/annotation style, and this schema is small and query-shaped, so plain SQL is clearer. Its bundled SQLite driver is attractive (see “Revisit if” below) but not needed yet. |
| Realm / MongoDB Atlas Device SDK | Deprecated; not SQL. |
| ObjectBox or other non-SQL stores | Weaker fit for transactional revision writes, ad-hoc queries and SQL migration tests. |
| Platform-specific stores (Room on Android, Core Data/SwiftData on iOS) | Would duplicate persistence and revision rules per platform, which contradicts D-01. |
Consequences.
- Schema changes ship as numbered
.sqmmigration files.verifyMigrationscompares them against checked-in schema snapshots insrc/commonMain/sqldelight/databases/. This meets the Phase 1 requirement for migration fixtures for every released schema. A snapshot must be generated for each released schema version. - A visible note change and its immutable revision are written in a single SQLite transaction (D-04).
- Android and iOS use the platform’s system SQLite. Features that depend on the SQLite version or build flags, such as FTS5, are not guaranteed on every supported OS version. Release 1 search therefore uses a lower-cased plain-text column with
LIKEinstead of FTS. - The iOS app links the system library with
-lsqlite3, because the shared framework is static. - Encryption at rest currently relies on platform protection: Android file-based encryption of app-private storage, and iOS Data Protection. Adding SQLCipher would mean swapping or configuring drivers per platform. That is still possible, and is the open “on-device database encryption” question below.
Revisit if full-text search needs FTS5 on every supported OS version, or if we need an identical SQLite build on every platform. Also revisit if SQLCipher proves impractical with the SQLDelight native driver, or if web or desktop clients (D-07) need a driver SQLDelight doesn’t support well. Room KMP with its bundled driver would be the first alternative to evaluate. Because the database sits behind NotesCore, a switch is limited to the storage layer and its tests.
Decisions needed before Phase 1
| Question | Why it matters | Recommended initial direction |
|---|---|---|
| Affects KMP migrations, queries, encryption options, and testing. | Resolved by D-09: SQLite via SQLDelight. | |
| Determines complexity, storage use, and recovery behavior. | Resolved by D-37: full Markdown snapshots; the diff is worked out when viewed and never stored. | |
| Affects Markdown portability, navigation, and external edit behavior. | Resolved by D-23: stored separately; generated by an on-device model when available, otherwise the first line. | |
| Titles that keep changing while the user types, or that overwrite a title the user chose, would feel broken. | Resolved by D-28: one automatic generation after a pause while the title is automatic; otherwise only when the user taps Generate title. | |
| Automatic titles will sometimes be wrong. | Resolved by D-28: yes. A title set by hand is never replaced automatically (only by an explicit Generate title), and the title’s source is stored (D-23). | |
| How long does the Undo bar stay and what does it cover (D-24)? | Too short and users miss it; too long and it covers the screen. | What it covers is settled by D-24 (delete, delete selected, delete notebook, and moves; also restoring and emptying the trash, D-40). How long it stays is not recorded: both apps use 6 seconds, dismissed by the next action; record that as a D-xx or change it. |
| It cannot be undone once the data is purged. | Resolved by D-40: no dialog; the purge waits until the Undo bar has gone. | |
| A link label can follow the target’s current title or stay as typed. | Resolved by D-39: the target’s current title, unless the user chose the label. | |
| The models may not be installed or enabled even on supported devices. | Resolved by D-23 and D-30: no. The model is used only when the OS already reports it ready; the app never prompts or downloads, and otherwise the title is the first line. | |
| Affects toolbar semantics, cursor behavior, accessibility, and Markdown round-tripping. | Resolved by D-12: native live-preview editor on the platform text views over a shared RichText model; Markdown is stored/transported but never exposed in normal UI. | |
| Determines background work, storage APIs, and UI capability. | Resolved by D-59: iOS 17 and Android 9 (API 28). A written support policy is still to do. | |
| Affects dependencies, performance, recovery, and support. | Resolved by D-27: no; rely on OS protection on the device and end-to-end encrypt anything synced. | |
| What are revision retention limits? | Unbounded history may surprise users on storage consumption. | Keep all history in beta; measure usage and later add explicit, user-controlled retention. |
Decisions needed before the future folder-sync phase
| Question | Why it matters | Recommended initial direction |
|---|---|---|
| Which exact folder providers are supported? | SAF providers differ in rename, atomicity, and change detection behavior. | Support Android local storage first; mark third-party providers experimental until tested. |
| How are external file edits merged? | Defines the most visible data-safety behavior. | Preserve both versions and offer a merge/resolve flow; avoid silent auto-merge. |
| What is a managed-folder deletion? | An absent file can mean user intent, provider outage, or scan limitation. | Stage it as a potential deletion, with recovery/conflict checks before applying. |
| Does iOS have a first sync destination? | Sets future expectation parity and implementation scope. | Keep iOS local-first until a safe built-in destination is chosen. Proposal: hosted sync (D-33) is iOS’s destination; folder sync on iOS at most as a manual “Sync now” (docs/spikes/folder-sync.md Q8). |
Do exported and synced files carry Jotry metadata (front matter with note_id)? | Front matter lets a re-import match notes, but files look less native in other apps. The sync map can hold identity instead. | Resolved for export by D-42: export always writes front matter. Folder sync is still open: no by default, identity in the sync map (docs/spikes/folder-sync.md Q1). |
| How are file names and wikilinks handled? | Other tools (Obsidian) write [[wikilinks]] and rename files outside Jotry. | Human file names from the title; read wikilinks but write relative links (D-36); detect renames by content hash (docs/spikes/folder-sync.md sections 1–2). |
Decisions needed before cloud sync
| Question | Why it matters |
|---|---|
| Changes auth, support, compliance, cost, and control. Resolved by D-33: product-hosted on Jotry’s own server. Whether users may also self-host it is still open. | |
| Determines key management and which server-side features are possible. Resolved by D-27: yes, for anything that leaves the device. | |
The README promises “no account”; affects support, subscription restore and what the server stores. Proposal in docs/spikes/monetization-and-sync-tiers.md. Resolved by D-45: account-free sync groups with QR pairing and a recovery key. | |
| Shapes billing, server-side receipt checks and store disclosures. D-34 limits it: export and folder sync stay free. Resolved by D-53 and D-52: a free app with a sync subscription bought in the stores; entitlement belongs to the sync group, so one purchase covers every device and any store can satisfy it. | |
| Are shared notebooks/collaboration planned? | Changes identity, permissions, conflict resolution, and data model requirements. |
| Defines backup, device-loss, key-loss, and support workflows. Resolved by D-48 for hosted sync: QR enrollment plus a 24-word recovery key; losing all devices and the recovery key loses the synced data. | |
| Decides whether receipts, rate limits, backups and uptime matter, and whether the server code (Edge Functions or a small service) is needed. Resolved by D-45: the owner’s own devices for now. | |
| Auth is built on users; devices that share notes need one user, or a group table with membership rules. Affects the README promise “no account”. Resolved by D-45: account-free sync groups. | |
| iOS needs HTTPS (or an App Transport Security exception) and Android blocks cleartext by default; away from home nothing syncs unless the Mac is reachable. Resolved by D-45: the server runs on the owner’s backend infrastructure behind HTTPS, not on the Mac. | |
What happens to the server/ Ktor module (D-33’s server)? | The working plan is to build the sync server on it, with the logic in a framework-free module and Ktor as an adapter (D-60). The Ktor module stays unless the framework is swapped, in which case the old adapter is removed so nobody maintains two. Revisit when the prototype is done (docs/pending.md, epic 2). |
| One machine holds the only hosted copy; the data is ciphertext, but losing it loses cloud history. Superseded by D-45: the server is not on the Mac. Replaced by the next row. | |
| The records, versions and change feed are what the server stores and what the sync engine (epic 6) codes against. A proposal is in sync-system-design.md section 7 (one record per group and record id, a per-record version for conditional puts, immutable revisions, tombstones, a per-group sequence for the change feed). Not accepted yet (2026-10-09); epic 2 builds only what doesn’t depend on it: groups and entitlements. Resolved by D-65. | |
| It is the client–server contract for every app. Candidates: JSON with base64 envelopes, or a binary format (CBOR, Protocol Buffers, MessagePack and others). Resolved by D-63: CBOR, with the classes in a shared Kotlin module. | |
| How is the sync server’s Postgres backed up and restored? | The data is ciphertext, but losing it loses every device’s hosted history. The working plan is a managed Postgres with its own backups. The provider, the retention window and a restore test are chosen when going live (docs/pending.md, last epic). |
Risks and mitigations
| Risk | Mitigation |
|---|---|
| Data loss from sync conflict or interrupted write | Transactional local commits, immutable revisions, precondition writes, idempotent outbox, conflict copies, fault-injection tests |
| File-provider inconsistencies | Capability flags, narrow initial provider support, post-write verification, and explicit degraded-mode UX |
| Premature third-party plugin surface | Start with internal extension API and reference implementation; add distribution only after isolation/security design |
| Editor behavior differs by platform | Keep native editors; specify shared formatting command behavior and test platform adapters |
| History storage grows unexpectedly | Begin with measurements and transparent storage reporting; introduce user-controlled retention only with safe export/recovery |
| Future desktop/web forces a rewrite | Keep domain, sync protocol, and document format platform-neutral from the beginning |
Extension Design Reference
This reference catalog records the kinds of extensions users commonly expect from a Markdown-first note application. It is informed by established Obsidian community-plugin patterns, but it is not a commitment to reproduce those plugins or their APIs. It guides future platform boundaries, data contracts, permission design, and roadmap choices; Release 1 ships no operational extension or plugin runtime.
Design rule
Design extension points around declared capabilities, stable data contracts, and explicit user grants. Mobile extensions should be limited integrations rather than arbitrary executable code. A later desktop app may offer more powerful automation, but only with a separate trust model and clear per-capability permissions.
1. Sync destinations and backup
Ecosystem examples
- Remotely Save: connects a file-based vault to S3-compatible storage, WebDAV, Dropbox, OneDrive, Google Drive, Box, and other services; it also exposes scheduling, encryption, and conflict-handling choices.
- Obsidian Git: treats a Git repository as a versioned backup/sync workflow, primarily on desktop.
Expected extension capabilities
- Authorize/deauthorize access and securely store credentials.
- Discover remote changes; fetch, conditionally write, and delete items.
- Declare remote history, tombstone, conflict-signal, stable-ID, and background-sync support.
- Report destination status, retries, user action needed, and storage/scope disclosure.
- Optionally provide backup-only or one-way export semantics.
Product implications
The app’s local revision history remains authoritative. When later implemented, file-style destinations—including the Android local-folder extension—can exchange current Markdown state but must never claim that they preserve application-managed history. Cloud providers need an additional security, conflict, and data-retention review before launch.
2. Templates and fast capture
Ecosystem examples
- QuickAdd: provides a single action for templated note creation, appending a capture to a known note, forms, multi-step macros, and optional scripts/AI.
- Templater: creates dynamic templates using variables and functions; it can execute arbitrary code, which makes it powerful but unsuitable as a direct mobile-plugin model.
- Calendar: creates and navigates daily/weekly notes based on an existing template convention.
Expected extension capabilities
- Register a template and request user inputs through a controlled form schema.
- Create a note in a notebook or append content to a user-selected/declared target.
- Resolve a safe built-in variable set: date/time, selected text, note metadata, clipboard with permission, and app deep links.
- Register commands or share-sheet capture actions with a clear name, icon, destination, and preview of the resulting formatted note; the app serializes Markdown internally.
Product implications
Provide these as built-in automation primitives first: templates, daily-note conventions, form fields, capture actions, and command registration. Do not initially expose arbitrary JavaScript, shell access, or unrestricted file writes. The extension API should compose approved actions rather than execute unreviewed scripts.
3. Structured views over notes and metadata
Ecosystem examples
- Dataview: queries Markdown front matter and inline fields to generate lists, tables, grouped results, and reports.
- Tasks: indexes Markdown task items across notes, supports recurring/due/completed states, and renders filtered task views that update source text.
- Calendar and Kanban: provide calendar, daily-note, and board views over note/task data.
Expected extension capabilities
- Read an indexed, permission-scoped projection of note metadata, tags, links, dates, and parsed task items.
- Register a view with declarative filters, grouping, sorting, and display configuration.
- Provide a structured query language or an app-owned query builder before exposing raw database access.
- Request narrowly defined mutations—for example, updating a task checkbox or a
statusmetadata field—through domain use cases that create revisions and sync events.
Product implications
Do not let a view plugin maintain an unsynchronized parallel database. The app should own indexing, execute queries locally, enforce notebook/content permissions, and ensure every plugin mutation passes through normal revision/history and sync behavior. Start with built-in search, task parsing, and basic list/calendar views; consider a public query/view API only once those contracts are stable.
4. Research, import, and external-library integrations
Ecosystem examples
- Zotero Integration: imports citations, bibliographies, notes, and PDF annotations.
- Zotero Sync: incrementally mirrors a Zotero library as Markdown for linking and search while keeping the source library read-only.
- ZotFlow: supports a deeper research workflow including reading/annotating and generating source notes.
Expected extension capabilities
- Connect to an external account, local service, share sheet, or imported file using a scoped user grant.
- Map external objects to a stable external ID and an app note ID.
- Import Markdown/content and attached metadata with a preview, target-notebook choice, and duplicate policy.
- Support one-way import, read-only mirror, or explicit two-way sync; require the extension to declare which model it uses.
- Expose provenance: source service, import time, original URI/identifier, and refresh behavior.
Product implications
Treat imports as revisions with a distinct reason and origin, so users can see where content came from and recover an earlier state. Preserve external IDs in app metadata/front matter only when portability requires it. Never allow an integration to overwrite a user-edited note without a visible conflict path.
5. Automation and system integrations
Ecosystem examples
- QuickAdd supports multi-step macros, external triggers, and optional scripts.
- Shell Commands runs user-configured system commands from Obsidian on supported desktop operating systems.
- Obsidian Git illustrates a desktop workflow that depends on local system tooling and is not available in the same way on mobile.
Expected extension capabilities
- Register deep links, application commands, widgets/shortcuts where the OS permits, and share-sheet entry points.
- Receive a narrowly scoped trigger payload such as selected text, URL, or audio/transcript result after explicit user approval.
- Compose permitted app actions: create note, append capture, assign notebook, set metadata, invoke an approved sync, or open a view.
- On desktop only, potentially call a reviewed local automation bridge with granular consent, visible command preview, audit log, timeout, and revocation.
Product implications
Keep an automation manifest separate from the sync adapter contract. A mobile implementation should support intent/deep-link/share-sheet automations without code execution. Desktop scripting, if ever introduced, is a high-risk capability and requires a dedicated permission model—not merely an extension installation toggle.
6. AI providers and local models
Ecosystem examples
- QuickAdd can optionally route prompts to OpenAI, Anthropic, Gemini, OpenAI-compatible providers, and local models as part of a user-created workflow.
- Many note workflows use AI for summaries, suggested tags, rewriting, extraction, and semantic retrieval rather than as a replacement for note ownership.
Expected extension capabilities
- Declare provider type, model, data sent, storage/logging policy, cost model, and whether processing is local or remote.
- Request selected text, current note, an explicitly chosen notebook subset, or local search results—never the entire library by default.
- Return a proposed change, new note, tags, summary, or retrieval results for user review.
- Support bring-your-own key through platform secure storage and allow the user to revoke/delete provider credentials.
Product implications
AI must be opt-in, transparent, and reviewable. The default interaction is a previewable proposal that becomes a normal local revision only after the user accepts it. Keep AI provider contracts separate from core sync so that an AI service cannot inherit permission to read all notes or access sync credentials.
Prioritization
| Priority | Extension surface | Reason |
|---|---|---|
| Release 1 | Documentation/types only for future sync and extension boundaries; no runtime, destination, or plugin | Preserves a path forward while keeping the initial release entirely local. |
| Future post-beta | Sync destination contract and Android local-folder implementation | Validates destination lifecycle and capability disclosure after the local core is proven. |
| Later | Templates, capture actions, daily notes, import/export hooks | High user value with a manageable, safe API surface. |
| After core metadata/index contracts stabilize | Structured query and view API; task/calendar/board views | Avoids freezing a fragile data/query model too early. |
| After cloud and permission work mature | External research integrations and AI providers | Both require robust credential, provenance, privacy, and conflict design. |
| Desktop-only future | Advanced automation and system-tool bridges | Powerful but requires a substantially stronger security/trust model. |
Reference links
- Dataview
- Tasks
- Calendar
- QuickAdd
- Templater
- Remotely Save
- Zotero Integration
- Zotero Sync
- ZotFlow
- Shell Commands
Cloud sync, accounts and end-to-end encryption (design ideas)
Status: future plan, not a decision. This document explores options only. Nothing here is agreed or built. Decisions live in
docs/project-plan/05-decisions-and-open-questions.md; the one relevant so far is D-27 (OS protection on the device, end-to-end encryption for anything that leaves it). Update 2026-10-06: the backend is now decided by D-33: Jotry’s own end-to-end encrypted server (section 5). The managed-backend options in addendum A are kept for the record only. D-35 confirms that hosted sync carries the full revision graph (section 2). Update 2026-10-08: D-45 makes hosted sync personal, with account-free sync groups, on the owner’s backend infrastructure behind HTTPS; section 10 compares server frameworks. Self-hosted Supabase (D-44, refined by D-45) is kept as the alternative backend in section 9; the crypto, key and sync-engine design applies to either. How sync fits paid tiers and store release, and the proposal to use account-free sync groups with QR pairing instead of sign-in, are inmonetization-and-sync-tiers.md. Everything else here is still a proposal: if any of it is adopted, record it as a new D-xx decision and updatedocs/project-plan/.
Context
Jotry is local-first (Kotlin Multiplatform, SQLDelight/SQLite, native Android/iOS UI, a Ktor server/ stub). Release 1 has no account or sync. docs/project-plan/03-sync-and-extension-platform.md and 05-decisions-and-open-questions.md already reserve the seams: stable note/revision IDs, immutable revisions with parentRevisionId + contentHash, tombstones (deletedAt), a destination capability contract, and an idempotent outbox. They also list open decisions: E2EE requirement, key recovery, device enrollment, metadata leakage. This is a design for Phase 4 (history-aware cloud sync) that fits those seams. No code is changed now.
1. Core principle
The server is a dumb, encrypted blob store plus an auth service. It never sees plaintext, titles, notebook names or note structure. Everything below follows from that.
2. What gets synced
Sync the revision graph, not “current note state”. Each unit is an immutable, encrypted record:
revision(noteId, revisionId, parentRevisionId, markdown snapshot, timestamps, titleSource)noteMeta(notebookId, pinnedAt, deletedAt) as a small mutable-by-supersession recordnotebook(name, order, archived)
Revisions are immutable, so they sync as append-only: no merge on the wire, and conflicts only show up as divergent branches in the graph. That matches the existing rule “divergent branches must be preserved, never silently discarded”. For a divergence, keep both heads, auto-create a “conflict copy” note (or a merge revision with two parents later), and surface it in the existing History UI.
Mutable metadata (pin, notebook move, rename, order) uses per-field last-writer-wins with a hybrid logical clock (HLC), not wall clock. Deletion is a tombstone that wins over older edits but is overridden by a later restore.
3. Encryption design
- Account root key (RK): 256-bit random, generated on the first device, never leaves devices in plaintext.
- Per-record keys: derive with HKDF from RK (
info = "jotry/v1/record" || recordId), or wrap a random per-note data key under RK. Per-note keys make future note sharing possible without exposing RK. - Cipher: XChaCha20-Poly1305 (or AES-256-GCM). Bind
recordId,noteId,schemaVersionandcontentHashas AAD so the server cannot swap or replay ciphertexts across records. - Envelope format:
{v, keyId, nonce, ciphertext}with a payload version from day one (the doc already requires versioned transfer payloads). - Library: libsodium via a KMP binding (e.g.
libsodium-bindings/ionspin) for shared code; fall back to platform crypto (Android Keystore/Tink, iOS CryptoKit) behind anexpect/actualCryptointerface. Do not roll your own primitives. - Local key storage: RK wrapped by a key in Android Keystore / iOS Keychain (
ThisDeviceOnly, no iCloud keychain sync, consistent with D-10/D-11).
Login vs. encryption must be separate
Use two independent secrets so the server can authenticate you without being able to decrypt:
- Auth: an opaque PAKE such as OPAQUE (or SRP) so the password is never sent to the server. Simpler fallback: email magic link / passkeys (WebAuthn) for auth only.
- Encryption: RK, protected by the user’s recovery material (below). Never derive RK from the same server-visible value used to log in.
4. Key management and recovery (the hard part)
Pick one primary model; offer the second as opt-in.
| Option | How | Trade-off |
|---|---|---|
| A. Recovery key (recommended default) | On enabling sync, generate a 24-word / 128-bit+ Recovery Key shown once, user saves it (print, password manager). RK is stored on the server wrapped under a KEK derived from it. | Strong, simple, no password-guessing risk. Lose all devices and the key and the data is gone, so say that clearly. |
| B. Password-derived key | KEK = Argon2id(password, salt), wrap RK. | Familiar UX, but offline-guessable if the server leaks; needs strong parameters and password-strength enforcement. Combine with A for safety. |
| C. Device-to-device enrollment | New device shows a QR code or short code; an existing device approves via an ECDH (X25519) handshake and sends RK wrapped to the new device’s public key. | Best UX when you still have a device. No recovery key needed in the normal path. |
Decided in D-48: C for adding devices and a 24-word recovery key (A) as the break-glass recovery, no password option. Original suggestion: C for adding devices + A as the break-glass recovery, with optional passphrase on top (B) for people who will not store a recovery key. Include key rotation (rewrap the RK-wrapped keys, and lazily re-encrypt records) and device revocation (rotate RK when a device is removed; revoked devices lose server access immediately even if they cannot lose the old key).
5. Server design (server/, Ktor)
Replace the hello-world with:
- Auth: account registration, OPAQUE/passkey login, short-lived access tokens + rotating refresh tokens, per-device tokens, device list and revoke.
- Storage: Postgres for accounts/devices/records metadata; object storage (S3-compatible) for large blobs if needed.
- Record API (all payloads opaque):
GET /v1/changes?cursor=…returns encrypted records after a server-assigned monotonically increasing sequence number (thechangeTokenscapability).PUT /v1/records/{id}withIf-Match/ expected-version precondition (theconflictSignals+atomicWritecapabilities); idempotent byrecordId(revisions are write-once, so replay is a no-op).- Tombstones are records, not row deletes.
- Batched push/pull, with
412mapped to “fetch then re-reconcile”.
- Realtime nudge: websocket/SSE or push notification only says “something changed”; the client then pulls. No content in pushes.
- Server-visible metadata (be honest about this): account/email, device list, record IDs, sizes, timestamps, sync frequency. Mitigate with padding to size buckets, opaque random record IDs (HMAC of note ID under RK, so the server cannot link records to notes), and no plaintext titles/names. Document this as the metadata threat model.
- Export/delete-account endpoint; server operators can only delete ciphertext.
6. Client architecture (fits existing seams)
SyncCoordinator+CloudDestinationin a new KMP module (e.g.shared:sync), implementing the adapter contract already specified:discover(cursor),fetch,push(change, precondition),delete,getStatus, with capabilitiesremoteHistory, remoteTombstones, stableItemIds, changeTokens, atomicWrite, conflictSignals, bidirectional, backgroundSync.- Add to the SQLDelight schema:
outbox(eventId, recordId, op, createdAt, attempts),sync_state(destinationId, cursor, lastSuccessAt),remote_map(recordId, serverVersion). Outbox rows are written in the same transaction as the revision (already the plan). - Pipeline: local commit → outbox → encrypt → push with precondition → ack (clear outbox only after success) → pull changes → decrypt → reconcile by parent/hash → fast-forward or stage conflict.
- Background: WorkManager (Android) and BGTaskScheduler (iOS) plus a foreground sync on app open and after edits (debounced). Everything idempotent and resumable.
- Search stays fully local, which is a privacy win: no server-side search. Remote data is indexed only after decrypt.
- Settings UI: Account (sign in/out; or, with account-free sync groups as proposed in
monetization-and-sync-tiers.md, “Add a device” with a QR code and “Leave sync”), Sync status (pending count, last success, errors), Devices, Recovery key, “Delete cloud data”. Keep the app fully usable signed out (sync stays opt-in; the README promise “never needs an account” stays true). - Update
README.mdprivacy section anddocs/project-plan/05-decisions-and-open-questions.md(new decisions: E2EE required, key model, auth method, conflict model).
7. Phased delivery
- Decisions + threat model (E2EE required; recovery model; PAKE vs passkeys; what metadata is acceptable). Write them into doc 05.
- Crypto + envelope module with test vectors,
expect/actualplatform key storage, versioned payload format. - Server MVP: auth, devices, change-feed + conditional put, Postgres, contract tests.
- Sync engine on one platform (Android first) with fault-injection tests: interrupted upload, duplicate delivery, two devices editing offline, clock skew, tombstone vs edit.
- Device enrollment + recovery key + revocation/rotation.
- iOS parity, then desktop/web reusing the same protocol (Phase 5 in the roadmap).
- Hardening: external crypto review, rate limiting, account deletion, metadata padding.
8. Open questions to settle before building
- Is E2EE mandatory for all sync, or is there a non-E2EE tier? (Strong recommendation: mandatory, single mode.)
- Recovery: is “lose all devices and the recovery key = lose the data” acceptable?
- Self-hostable server (users run their own) vs hosted only? Self-hosting reduces trust in the operator and suits the privacy brand.
- Sharing/collaboration later? If yes, adopt per-note keys now.
- Free vs paid storage limits; how much history to sync by default (full graph vs recent N revisions + lazy fetch).
- Attachments/images: chunked + encrypted blobs, content-addressed by hash of ciphertext.
9. Self-hosted Supabase (the alternative to an own server, D-45)
D-45 leaves the backend open between a self-hosted Supabase stack on the backend infrastructure and an own Kotlin server (section 10). The server is a dumb store for ciphertext either way, and sections 1–4 and 6 apply to both. Facts about Supabase come from its docs and have not been run by us.
What Supabase would give, and what it wouldn’t
- No server code for the first version: Postgres tables, row-level security (RLS), a SQL function for the conditional put, Supabase Auth, and Realtime for the “something changed” nudge. Section 5’s
GET /v1/changesandPUT /v1/records/{id}become PostgREST calls and one RPC. - Not covered: account-free sync groups (Supabase Auth is built on users, so groups need a membership table and rules around it), OPAQUE, store receipt checks (an Edge Function or a small service), and per-device revocation beyond Auth sessions.
- Shared code keeps the adapter rule from addendum A: a
SupabaseDestinationbehind theCloudDestinationcontract, using the Ktor client against the REST endpoints, with no Supabase SDK in shared code.
Services to run
The official self-hosting setup is a Docker Compose file routing everything through a Kong gateway. The minimum (inferred from the docs):
| Service | Needed | Why |
|---|---|---|
| Postgres | yes | Records, devices, RLS |
| Auth (GoTrue) | yes | Sign-in, JWTs for RLS |
| PostgREST | yes | The record API |
| Realtime | yes | Change nudge |
| Kong (gateway) | yes | One URL, key checks, routing |
| Studio, meta | no | Dashboard; run on demand while developing |
| Storage, imgproxy | later | Attachments only |
| Edge Functions | later | Receipt checks |
| Analytics, vector, pooler | no | Logging and pooling for larger setups |
Five always-on services are well over the few hundred MB the backend infrastructure can spare (D-46), which is a large part of why section 10 recommends an own server.
Data model sketch (not final)
-- all payload columns are ciphertext; ids are opaque
create table sync_group (id uuid primary key default gen_random_uuid(), created_at timestamptz default now());
create table sync_member (group_id uuid references sync_group, user_id uuid references auth.users, device_label text, added_at timestamptz default now(), primary key (group_id, user_id));
create table record (
group_id uuid not null references sync_group,
record_id text not null,
seq bigint generated always as identity, -- change cursor
version int not null default 1,
envelope bytea not null,
created_at timestamptz default now(),
primary key (group_id, record_id)
);
-- RLS: a row is visible only to members of its group
-- rpc put_record(group, id, expected_version, envelope): inserts or updates when the version matches,
-- returns the new seq, or a conflict marker the client maps to "fetch then reconcile"
Secrets: the default Supabase .env ships demo keys. Generate our own JWT secret and keys, keep them out of git, and never put the service-role key in an app.
10. Server framework comparison (D-45, 2026-10-08)
D-45 fixes the scope (the owner’s own devices), the identity model (account-free sync groups) and the reach (the backend infrastructure behind HTTPS). It leaves the backend open between a self-hosted Supabase (section 9) and an own Kotlin server. Account-free groups are the case Supabase Auth fits worst, so this section compares Kotlin servers. The requirements are lean, a Kotlin codebase, and an easy path to scale if the app takes off. Framework facts come from docs and community posts; I found no published benchmark that compares all of them on the same load, so the numbers below are directional. Step K4 measures them, late, on the real hosting.
What the server has to do
About ten endpoints: register a device into a sync group, authenticate, GET changes?cursor, conditional PUT record, batch push and pull, a change nudge (SSE), delete a group. Every payload is opaque ciphertext. There is no templating, no ORM-heavy domain model and no admin UI. Throughput is bound by Postgres, not by the framework.
Candidates
| Framework | Lean | Kotlin fit | Scale story | Notes |
|---|---|---|---|---|
Ktor (3.6.0, already in server/) | Light, modular plugins; coroutines. JVM RSS is tens to a few hundred MB depending on tuning. | Kotlin-first, JetBrains. Shares core types with the clients, and Ktor is the natural client library for the KMP side. | Stateless instances behind Caddy; coroutines handle many idle SSE connections cheaply. | Runtime DI (manual or Koin). Native image is possible but less turnkey than Quarkus. Fewer batteries, which suits ten endpoints. |
| Spring Boot (Kotlin) | Heaviest: guides suggest 512 MB–1 GB comfortable on the JVM; slower start. | Good Kotlin support, coroutines through WebFlux. | Largest ecosystem and hiring pool; proven at any scale. | Overkill for this server. Choose it for a big team or lots of integrations (OAuth, admin, batch). |
| Quarkus | Best native-image story; reported tens of MB RSS and millisecond starts as a native build. | Java-first; Kotlin works with some friction (annotation processing, allopen). | Cloud-native, fast restarts, scale-to-zero. | Pick it if memory per instance is the top concern. |
| Micronaut | Compile-time DI keeps start and memory low. | Kotlin through KSP; smaller community. | Similar to Quarkus. | Reasonable, but the least common choice here. |
| http4k | Tiny, functional, server-agnostic. No published numbers found. | Kotlin-first. Handlers are plain functions, so tests need no server. | Stateless by design. | Fewer built-in pieces (auth, rate limits, SSE) than Ktor; commercially backed, smaller community. |
| Javalin / Vert.x | Both light. | Javalin is simple but not coroutine-native; Vert.x is fast but low-level. | Fine. | No clear advantage over Ktor for us. |
Recommendation (to confirm)
Ktor with Postgres. It is already in the repo, it is Kotlin-first and coroutine-based, JetBrains maintains it (the same company as the KMP and Compose stack we use), and ten endpoints don’t need Spring’s batteries. The framework is not what limits scale; these are:
- Stateless instances. Verify device signatures or short-lived tokens locally, with no server-side sessions, so more instances sit behind Caddy.
- The change cursor. A plain Postgres sequence can expose rows out of commit order, so a client could skip a record it should have received. Use a per-group counter assigned under a row lock (or a commit-safe cutoff) so the cursor never skips. This is the part of the design to test hardest (step S2).
- The nudge. One instance can keep SSE connections in memory. With several instances, publish through Postgres
LISTEN/NOTIFY(or Redis). Not needed for one server. - Memory. The backend infrastructure is amd64 and shared with many other services, so the budget is a few hundred MB for the sync server. Cap the JVM heap (for example
-Xmx128mto-Xmx192m, plus-XX:MaxMetaspaceSizeand a small thread stack) and measure RSS, not heap. Postgres needs its own share (tuneshared_buffersto tens of MB), or reuse a Postgres instance that already runs there (open question). A plain JAR is architecture-neutral, so building on the Mac (arm64) and running on amd64 needs no cross-compile; a native image would have to be built on an amd64 Linux host. If memory per instance ever matters, move to Quarkus or a native image; the endpoint code is small enough to port.
Runner-up: http4k, for the leanest code and easiest tests. Take Quarkus instead if native-image memory matters more than staying with Ktor. Take Spring Boot only if the server grows beyond the sync API (accounts, billing, admin).
Server structure (D-60)
The logic sits in a framework-free module behind interfaces, and the web framework, the Postgres store and the store verifiers are adapters. That keeps this section’s framework choice cheap to reverse. The layout, rules and local Docker Compose environments are in server/README.md.
Account-free auth sketch (to design in step K2)
- A group is a random 128-bit id plus a recovery-key-protected root key held by devices only.
- Each device creates an Ed25519 key pair. Enrolling a device means an existing device approves it over the QR handshake (section 4, option C), and the server stores the new device’s public key under the group.
- A device requests a short-lived token by signing a server challenge with its private key. Revoking a device removes its public key; rotating the root key follows section 4.
- The server never sees a password, an email or any key that decrypts a record.
Work order (replaces section 9’s order if Ktor is confirmed)
| # | Step | Done when |
|---|---|---|
| K1 | Prototype: conditional put and the change feed on Postgres in the local Docker Compose development environment (server/README.md), with the logic behind interfaces (D-60). Optionally the same two endpoints in http4k or Quarkus, as adapters over the same logic. | The two endpoints pass tests against a real Postgres in a test container; a phone reaches the dev server over HTTP on the local network. |
| K2 | Group and device auth: enrollment, challenge-signing, token, revocation, with tests | A revoked device is refused; replayed challenges are refused; no endpoint accepts an unauthenticated request. |
| K2b | Entitlement and recovery: the pluggable verifier (development first; Apple against recorded signed transactions), entitlement rows, read-only lapsed groups, one-year deletion, and the recovery-key flow (sync-system-design.md sections 4 and 5; D-52 to D-58) | A group without an active entitlement cannot push but can pull and recover; a mismatched account token is refused; the development verifier is refused in a production build; a new device with only the recovery key re-joins and reads everything. |
| K3 | Schema, migrations (Flyway or similar) and cursor with tests against a real Postgres (Testcontainers) | Concurrent writers never make a client skip a record; put_record is idempotent and rejects a stale version. |
| S3 | Crypto envelope module (shared, no backend) | Known-answer and tamper tests pass on JVM, Android and iOS. Done, see section 11 and sync-system-design.md. |
| S4 | Sync tables and engine core: outbox, sync_state, remote_map; CloudDestination; a fake destination | Fault-injection tests pass against the fake. |
| S5 | KtorDestination (the Ktor client in shared code) against the real server | The same suite passes against the server in a JVM host test. |
| S6 / S7 | Android, then iOS: QR enrollment, recovery key, status | Two devices converge after offline edits; then an iPhone and an Android phone. |
| K4 | Measure before go-live: start time, idle RSS and p95 latency for 100 concurrent pushes, on the real hosting. Pass bar (proposal): idle RSS of the server process under about 200 MB with a capped heap, and under a few hundred MB together with Postgres. If the JVM misses it, try Quarkus native or http4k as another adapter (D-60) before changing the plan. | The numbers are written down and the bar is met or the plan is changed. Done late, once the real hosting is known. |
Verification (when implemented)
- Crypto: known-answer test vectors; tamper tests (flip a byte, swap record IDs → decrypt must fail).
- Server: contract tests for preconditions, idempotent puts, cursor pagination; confirm the DB contains no plaintext (grep fixtures for sentinel strings).
- Sync: two-emulator offline-edit scenarios; kill the app mid-push; replay duplicate batches; verify no silent data loss and conflicts appear as preserved branches.
- Security review of auth and key flows; run the
security-reviewskill on the branch before release.
Addendum: answers to follow-up questions
A. Do I have to write and run the server? (Firebase / Supabase / similar)
No. With E2EE the server only authenticates users and stores opaque encrypted records, so a managed backend fits well. The “dumb blob store + auth” model in section 5 is exactly what BaaS products provide.
| Option | Fit | Notes |
|---|---|---|
| Supabase (Postgres + Auth + Realtime + Storage + RLS) | Best fit | Row-level security gives per-user isolation. A records(user_id, record_id, seq, version, ciphertext, created_at) table plus a Postgres sequence gives the change cursor. A small SQL function / Edge Function does the conditional put (WHERE version = expected). Realtime channel provides the “something changed” nudge. Self-hostable later, which suits the privacy brand. |
| Firebase (Firestore + Auth + FCM) | Works | Firestore listeners and FCM give easy realtime. Cursor/ordering and conditional writes are doable via transactions but less natural than SQL. More vendor lock-in; no self-hosting. |
| PocketBase / Appwrite | Works for small scale | Self-hosted single binary; fewer moving parts. |
| Plain object storage (S3/R2) + signed URLs + auth provider | Works | Cheapest, but you must build the change-feed index yourself. |
Own Ktor server (existing server/ stub) | Most control | Only worth it for OPAQUE, custom rate limiting, or strict data residency. |
What changes versus the custom-server design:
- Login: BaaS auth is normally password-to-server (bcrypt), email link, OAuth or passkeys. That is fine as long as login is decoupled from encryption (already required in section 3). The password must not be what unlocks the data; use the recovery key / device enrollment for RK. Avoid OPAQUE initially; use passkeys or email magic link for auth.
- Metadata: the provider sees email, IPs, timestamps, sizes. Same threat model as section 5, but also a third party. State it in the privacy docs.
- Client: a KMP client talks to a thin
CloudDestinationadapter (REST via Ktor client against Supabase PostgREST/RPC, or the Firebase REST API), so the provider can be swapped without touching the sync engine. Do not adopt provider SDKs inside shared code; keep them behind the adapter. - Leaning (not decided): Supabase (hosted) behind a thin adapter, with schema/RPC definitions kept in-repo as migrations. Revisit the Ktor server only if a requirement (OPAQUE, self-host packaging, compliance) demands it. The backend choice is explicitly deferred.
- Reopened by D-44 and D-45 (2026-10-08): self-hosted Supabase, on the owner’s own server, answers the third-party objection below; it is the alternative in section 9.
- Superseded by D-33 (2026-10-06): the own Ktor server was chosen instead, so that no third party sees sync metadata and so that account-free sync groups and server-side receipt checks stay under our control.
B. Are notes encrypted on the device at rest, and is it needed? (Outcome: recorded as D-27, no encryption at rest beyond the OS)
Current state (from docs/project-plan/05-decisions-and-open-questions.md): notes live in SQLite in app-private storage, protected only by platform encryption (Android file-based encryption, iOS Data Protection). App-level database encryption (SQLCipher) is explicitly an open question (“Is on-device database encryption required?”), to be decided before the schema is stable.
What the OS protects against: another app reading your data (sandbox), a lost/stolen locked phone (FBE/Data Protection keys are tied to the screen lock; on iOS default class is “until first unlock”, so data is readable after the first unlock until reboot), and cold-boot/flash-dump attacks. What it does not protect against: a unlocked phone, someone who knows the PIN, malware with root/jailbreak, forensic tools on some unlocked states, and (importantly for sync) any backup or extraction path that bypasses the sandbox. Android devices with no screen lock get effectively no protection.
Recommendation:
- Not required for launch/local-only. For a typical notes app the OS protection is the accepted baseline, and the README already tells users this. Adding SQLCipher now costs per-platform driver work, loses system SQLite/FTS options, and creates a recovery problem (lost key = lost notes), for modest extra benefit.
- Becomes more relevant with E2EE sync. Once the user holds an RK that makes the cloud ciphertext-only, a plaintext local DB is the weakest link and the thing users will point at. Reasonable stance: E2EE protects data off the device; the OS protects it on the device.
- Cheap middle path: make Keystore/Keychain keys
ThisDeviceOnly, require the OS biometric/passcode for opening RK if the user turns on “App lock”, and exclude from backups (already D-10/D-11). Encrypt only the RK and tokens at rest in Keystore/Keychain (required anyway), not the whole DB. - Optional later: an “Encrypt notes on this device” setting using SQLCipher with a Keystore/Keychain-held key, if the threat model (shared/rooted devices, compliance) demands it. Decide it before the schema stabilizes, because converting an existing plaintext DB later needs a migration (export, re-create encrypted DB, verify, swap).
- If we do encrypt the local DB, we can reuse the same envelope/crypto module from the sync work, but whole-DB (SQLCipher) is simpler than per-field encryption, which would break the local
LIKEsearch.
Decision to record in doc 05: “On-device encryption: rely on OS protection for Release 1; E2EE applies to sync; revisit SQLCipher with an explicit threat model before schema freeze.”
11. S3 result: the :crypto module (2026-10-08)
A first prototype used cryptography-kotlin with AES-256-GCM and per-record HKDF keys. The owner then chose libsodium, XChaCha20-Poly1305 and per-note data keys wrapped under the root key (D-47), and the module was rebuilt on that. The design, formats, auth and enrollment flows are in sync-system-design.md. Tests: NoteKeysTest, RecordCipherTest and DeviceEnrollmentTest, 21 in all, pass on JVM, iOS simulator and an Android emulator (API 37); known answers come from an independent Python script (HChaCha20 checked against draft-irtf-cfrg-xchacha, plus the cryptography package and hashlib).
Desktop app: framework, native build and testing (design plan)
Status: the build has started (D-76); the rest of this plan is still a proposal. D-76 records the platforms, the Nucleus shell with a plain dev window, our own editor adapter and the screens. The open questions it lists (native image or JVM for release, a shared Compose module, database encryption, minimum OS versions) are not decided. Before D-76 this was a future plan, not a decision. Desktop is Phase 5 in
04-roadmap.mdand out of Release 1 scope (D-07). This plan compares ways to build it, with the goal of a fast, lightweight app. Nothing here is agreed until it is recorded as a new D-xx. Several of the points below conflict with D-01 (native UI per platform) and D-12 (the editor is built on the platform text view). If we choose a Compose desktop app, new decisions must say how they apply to desktop.Research dates: 2026-10-07, with options E and F added 2026-10-08. Facts about third-party projects come from their docs, release notes and community threads. The spike results below replace those claims where we measured something ourselves. Desktop has to run on Linux, macOS and Windows, so option C (native macOS only) is out.
Spike results (2026-10-07, macOS)
Branch spike/desktop-nucleus. It has three modules: app/desktopUi (the screens, editor and database setup, with no Nucleus), app/desktopApp (the Nucleus release shell and native build) and app/desktopDev (a plain Compose window for development, see surprise 1). Together they contain a notes list and an editor on the shared NotesCore and EditorSession, Nucleus 2.5.18 with the tao backend, Compose Multiplatform 1.12.1, and SQLDelight with sqlite-jdbc writing to a database file. Everything below was measured on an M-series Mac (8 cores, 16 GB) in a session with no screen access, so nothing was checked by eye. Linux, macOS and Windows ran in CI on 2026-10-08 (see the table below).
What worked
-
GraalVM native image with SQLDelight. Nucleus downloaded GraalVM CE 25.0.4 itself. The
sqlite-jdbcnative-image feature was picked up without any configuration. Inside the native binary, the smoke run (JOTRY_SMOKE=1) edits a note throughEditorSession, saves it, reopens the file with a second core, searches, reads history and exports. It passes. No reflection config was written by hand. -
Native build time: about 1 min 10 s, with a peak of 2.9 GB of build memory (on 16 GB, native-image uses 8 GB at most). Nucleus’s docs say 5–15 min on CI runners.
-
Startup and memory (time to first frame and resident memory, the same app on each runtime):
Native image JVM (jlink’d runtime) First frame, warm ~235 ms ~700 ms First launch after a build ~2.9 s (macOS checks the new binary) ~2.5 s Idle memory, empty database ~127 MB ~246 MB Idle memory, 10,000 notes ~195 MB (142 MB with -Xmx64m)~273 MB App size 126 MB (binary 87 MB) 159 MB Seeding 10,000 notes through
NotesCoretook 13.7 s in native. The 10,000-note memory is mostly the list loading everyNoteSummaryat once, which the real app should page. -
The editor on
BasicTextField. It holds the visible text and sends each change toEditorSession.replaceTextas a prefix/suffix diff. Styling is drawn from the session’sRichTextthrough aVisualTransformation. Cmd+B, Cmd+I, Cmd+Z and Cmd+Shift+Z go to the session before the text field sees them, so the field’s own undo never runs. That matches the phones (D-12’s rule that platform undo is routed to the session). -
UI tests are fast and simple. Five editor tests and three database tests run in about 1 s with
runComposeUiTest, with no window, emulator or simulator. -
Hot reload works with the tao backend.
hotRunbrought the app up, which contradicts the Slack report. Through the hot-reload MCP server, an agent can reload changed code into the running app, andstatus,get_logs,get_ui_errorandrestartwork. -
Configuration cache, Kotlin 2.4.20 and Compose 1.12.1: no conflicts with the existing build.
CI on all three OSes (GitHub-hosted runners, .github/workflows/desktop-spike.yml, run 37787707391, before the module split):
| Linux (ubuntu-latest) | Windows (windows-latest) | macOS (macos-latest) | |
|---|---|---|---|
| JVM tests | pass | pass | pass |
| Native build | 5 min 8 s, 4.0 GB peak | 9 min 19 s, 3.4 GB peak | 12 min, 2.1 GB peak |
| Native smoke test (SQLDelight) | pass | pass | pass |
| App folder | 168 MB (binary 107 MB) | 136 MB (binary 92 MB) | 126 MB |
| First frame, warm | ~170 ms after main (~920 ms since the process started, under xvfb-run) | ~180 ms | ~420 ms |
| Result | pass | pass | hung on exit in the 2nd startup run (surprise 8) |
On Linux, about 750 ms pass between the process starting and main. That may be xvfb-run or loading the larger binary, and it needs checking on a real desktop.
Surprises (each one is fixed or worked around in the spike)
- The MCP server can’t see inside a Nucleus window.
take_screenshot,get_semantic_tree,clickandtype_texttime out with the tao window, andlist_windowsshows it with no title. A stock Compose desktop app (AWT window, no Nucleus) on the same machine and versions worked fully: tree, screenshot and window title. So with Nucleus, an agent can reload and restart the app but can’t look at it or drive it. Workaround in the spike: the screens live inapp/desktopUi, which has no Nucleus dependency, andapp/desktopDevshows them in a plain Compose window for development. Run it with./gradlew :app:desktopDev:hotRun -Pcompose.reload.jbr.autoProvisioningEnabled=true, and its MCP server with./gradlew :app:desktopDev:hotMcpServer. Through that server an agent read the UI tree as JSON (with the test tags, the editor text and the checkbox states), unchecked a task, typed into the editor, and saw the shared editor continue the checklist. The autosave then reached SQLite. Reading the tree costs far fewer tokens than screenshots. Window-level behavior (the tao title bar, menus, tray) is still checked only inapp/desktopApp. - A stock Compose window doesn’t work alongside Nucleus. With Nucleus on the classpath,
application { Window { } }crashes (“addObserver must be called on the main thread”). Nucleus redefines the main thread. You can’t fall back to a plain window in the same app, or use one for debugging. - About 85 MB of other platforms’ native libraries ended up in the binary. Native-image embedded Skiko for both Mac architectures, Windows ANGLE, Linux tao, and sqlite-jdbc builds for about 20 OS/CPU combinations, making a 149 MB binary. An
-H:ExcludeResourcespattern inapp/desktopApp/build.gradle.ktskeeps only the current platform’s libraries, which gives 87 MB. Nucleus doesn’t do this itself.optimization = SIZEsaved only 6 MB and doubled the build time, so it’s not used. - The JVM build left out
java.sql. The jlink’d runtime doesn’t include it and sqlite-jdbc needs it, so the JVM app crashed on open untilmodules("java.sql")was added. Native image wasn’t affected. - Toolbar buttons stole the editor’s focus, and the selection went with it, so Bold did nothing. The buttons are now not focusable (
focusProperties { canFocus = false }). Every desktop toolbar and menu action needs this. - Compose text fields can’t hold inline content. The phones put checkboxes in the text as a span or attachment. On desktop, list and task paragraphs get a text indent, and checkboxes, bullets and numbers are drawn in the gap from the text layout. It works and is tested, but it’s a different design from D-12’s adapters.
- Hot reload needs JetBrains Runtime 25. It downloads one with
-Pcompose.reload.jbr.autoProvisioningEnabled=true. On macOS, the JVMrunandhotRuntasks need-XstartOnFirstThreadfor tao, but that flag hangs a plain AWT window. - A
DecoratedWindowneeds aTitleBar. Without one, the content is drawn under the macOS window buttons (seen in a screenshot). Separately, the native build sometimes hangs when the app exits right after its first frame. Locally that happened in 2 of 5 runs with aTitleBarand 0 of 5 without one, but on the macOS CI runner it also hung without one. Exiting 2 s after the first frame worked 5 of 5 times, and the JVM build never hung, so it looks like a startup race that users quitting normally won’t hit. Measurement runs now wait 1 s before exiting. Worth reporting upstream. - Smaller things:
GRAALVM_HOMEon the dev machine pointed at Temurin, which would override Nucleus’s own GraalVM, so builds run with it unset. JDK 25 warns that sqlite-jdbc’sSystem.loadwill be blocked in a future release unless--enable-native-accessis set. SQLDelight’s checked-in.dbschema snapshots have nouser_version, so the migration test sets it.
Still open after the spike
- Memory on Linux and Windows (CI measured startup only), and startup on real desktops rather than CI runners.
- Code signing and notarization, installers (DMG/NSIS/DEB), and auto-update weren’t tried.
- IME composition (Chinese, Japanese), paste, and links in the desktop editor.
- Nucleus upgrades: issue #671 tracks Compose 1.13 breaking changes, and #585 reports high memory use when switching themes.
ComposeTextEditor spike (2026-10-08, macOS, local)
Question: bring in ComposeTextEditor 3.0.3 (MIT, desktop artifact com.darkrockstudios:composetexteditor-desktop), or keep our own editor (DesktopEditor, a BasicTextField with a VisualTransformation)? The spike is LibraryEditor.kt in app/desktopUi, next to DesktopEditor.kt and not wired into the screens, with five tests in LibraryEditorUiTest. GitHub CI is out of credits, so everything ran locally.
What the spike does. The library is only the text surface (layout, caret, selection, IME, clipboard). EditorSession stays the single model. The library’s editOperations (insert, delete, replace) are mapped from line/char to flat offsets and forwarded to session.replaceText. Its own list and block behaviors are switched off (editBehaviors.clear()), or Enter on a checklist line would be continued twice. Undo, redo, Bold and checklist come from the session, and the session’s styled document replaces the library’s text with setText. Cmd/Ctrl+Z, Y and B are caught before the library sees them. All five tests pass, and the existing eight still pass.
What it showed
- The library owns a second document model: lines of
AnnotatedString, rich spans, block spans (lists, tasks, quotes, fences, tables), a Markdown dialect with its own converter, and its own undo history. Using it as a pure text surface works, but that leaves ourRichText,MarkdownCodecandEditorSessionas the only model and uses a small part of the library. - Every session-driven change (undo, redo, formatting, Enter in a list, anything with
resetText) callssetText. That drops the library’s IME composition and its own state, and it is visible whenever the user is mid-word.DesktopEditoravoids this by only replacing the text when the session says so. - The library’s own undo history still records every edit. Cmd+Z is routed to the session, but the library’s context menu or touch toolbar Undo would bypass it (not tried).
- The checkbox gutter does not port. The library draws tasks, bullets and numbers as its own block spans, and exposes no single
TextLayoutResultto position our gutter on. Using its tasks means using its block model, and so a second Markdown codec to keep in step withMarkdownCodec(fidelity risk). - Text typed after a bold run only keeps the style through the library’s cursor styles, which differ from the session’s
typingStyles. Not tested. - Cost: a 1.6 MB jar, one new transitive dependency (
kotlinx-datetime), and a single-maintainer project (about 74 stars). The README itself says it may not be a permanent solution onceBasicTextField2can render rich text. Native image compatibility is untested.
What we’d gain. Lazy line layout for very large notes, a context menu, selection handles, accessibility and tuned input handling (dead keys, CJK composition) that we would otherwise build or test ourselves. DesktopEditor has IME composition, paste and links on its gap list.
Not measured: native image, typing latency on a 10k-line note, real key events and IME (the tests drive the state API), the library’s context menu.
Recommendation (to confirm): keep our own editor. Bringing the library in as a text surface keeps EditorSession intact, but it adds a dependency, a second undo history, setText resets and no checkbox support, in exchange for features a notes app rarely needs at note size. Revisit if BasicTextField shows real problems with IME or large notes. The spike code can stay on the branch as the reference for that.
Goal
A desktop Jotry that:
- Starts fast and stays small in memory. The bar is “feels like a native notes app”, not “feels like an IDE”.
- Uses the same
NotesCore, database and file format as the phones, so notes round-trip safely (the Phase 5 exit criterion). - Can be tested well: UI tests in CI, plus an agent (Claude Code) that can launch, inspect and drive the running app while we work on it.
What we start with
- Shared core:
app/sharedLogicalready has ajvm()target with the SQLDelightsqlite-driver(used for host tests today).corebuilds for JVM, JS and Wasm. A JVM desktop app can callNotesCorewith almost no new shared code. A Kotlin/Native macOS target (macosArm64) would need adding, but it uses the same SQLDelight native driver as iOS. - Android UI (
app/androidApp, about 19.5k lines with tests) is Jetpack Compose, but it is Android-only code. Moving screens into a shared Compose Multiplatform module means removing Android-only APIs (resources,ViewModelwiring, intents, the file picker, Gemini Nano titles). - The editor is the hard part. Per D-12, Android uses
EditText(JotryEditText,EditorSpans) and iOS usesUITextView. Neither exists on desktop Compose. The formatting logic is shared (EditorSession, covered byEditorSessionTest), so each platform only needs a text-view adapter. On desktop Compose that adapter has to be built onBasicTextField, or a Swing text component hosted throughSwingPanel. Nucleus’s tao backend (below) has no AWT, so it rules outSwingPanel. - iOS UI (
app/iosApp, about 5.2k lines of Swift) is SwiftUI with a TextKit editor. TextKit came from AppKit, so the editor is the part of the iOS app that ports to macOS most directly. app/desktopAppandapp/sharedUIare template modules from the project wizard. They aren’t built (seesettings.gradle.kts).
Options
A. Compose Multiplatform desktop on the JVM (official JetBrains plugin)
The default path. Ship a jlink’d JVM with the app (DMG/MSI/DEB through the Compose Gradle plugin).
- Speed and size: the weakest of the options. Cold start is typically around a second or more, and idle memory is a few hundred MB, because a full HotSpot JVM and Skia are bundled. JDK 25+ AOT cache (Project Leyden) and ProGuard reduce startup, but not to native levels. Measured in the spike: about 700 ms to the first frame and about 250 MB idle.
- Tooling: the best of the options. Normal IntelliJ debugging, Compose Hot Reload, and the hot-reload MCP server (see “Agent access” below) all target this setup.
- Risk: low. It’s JetBrains’ supported path.
B. Compose desktop with Nucleus (JVM for development, GraalVM native image for release)
Nucleus is an MIT-licensed layer on top of Compose Multiplatform desktop. It isn’t a different UI toolkit. It adds:
- Native windowing through a Rust
taobackend with no AWT (the AWT backends are deprecated, and the docs say they are removed in 2.6), decorated windows, a native menu bar, tray, notifications, deep links, file associations and single-instance locking. - Packaging for 18 formats, store publishing (Mac App Store, Microsoft Store, Snap, Flathub), signing and notarization, and an auto-updater (GitHub or S3, SHA-512, delta updates).
- GraalVM native image as a one-line switch, with reachability metadata generated automatically for dependencies. Its docs list SQLite, Ktor, kotlinx.serialization and Compose as working without extra configuration.
Reported numbers (from the project, not independent):
- A Hello-World Compose window cold-starts in about 0.2 s using about 30 MB of RAM (Windows 11).
- In their benchmark app (a heavy CPU and rendering stress test on an M4 Mac), native image reached a peak of 269–326 MB of RAM, compared with 616–695 MB on the JVM. App size was about the same (119–174 MB), because Skia dominates. A notes app should land well under those peaks, but it won’t get close to a Swift app’s size.
Concerns:
- Maturity: about 400 stars and 28 forks, mostly one maintainer, version 2.5.x, with a 3.0 branch in progress. Some real apps use it (Tasks.org, AB Download Manager, CrossPaste). If the project stalls, we’d need to fall back to the official plugin. That’s realistic, because the UI is plain Compose, but we’d lose native windowing and native image.
- Version coupling: it needs Compose Multiplatform 1.12.x, Kotlin 2.4.10+ and Gradle 9+. We’re on Compose 1.12.1 and Kotlin 2.4.20, so we fit today, but upgrades become gated on Nucleus releases.
- Hot reload works in the spike, despite a Slack report. The hot-reload MCP server, though, can’t screenshot or read the UI of a tao window (see the spike results).
- Native image limits: a closed world (no runtime reflection or class loading the build can’t see), no cross-compilation (build on each OS in a CI matrix), slower and memory-hungry builds, and debugging a native binary is much harder than on the JVM. In practice: develop and test on the JVM, and run a smoke test of the native build in CI.
sqlite-jdbcand SQLCipher: plain SQLite is listed as working. An encrypted database through SQLCipher on the JVM, and then inside native image, is a separate unknown (see the “Revisit if” note under D-09).
C. Native macOS app (SwiftUI/AppKit) on a Kotlin/Native core
Add a macosArm64 (and macosX64 if needed) target to sharedLogic, and build a macOS app that reuses the iOS SwiftUI code and the TextKit editor (NSTextView).
- Speed and size: the best of the options. A Swift app with a static Kotlin/Native framework starts in a fraction of a second and idles at tens of MB. No GraalVM step is needed: Kotlin/Native already compiles ahead of time.
- Fits D-01 and D-12 directly: native UI and a platform text view.
- Reuses a lot: iOS screens,
AppModel, the editor’s TextKit code, and the XCUITest setup. Apple’s on-device Foundation Models are available for generated titles on macOS too. Compose desktop has no equivalent. - Downside: macOS only. Windows and Linux would need option A or B later, so that would be a fourth UI.
- Testing: XCUITest on macOS works like iOS, but it’s slow, and agent access goes through the macOS accessibility tree (a computer-use or AX-based MCP) rather than a Compose-aware tool.
D. Others considered
- Tauri (a webview with a Rust shell): small binaries (about 10 MB) and low memory, but the UI and editor would be rewritten in web tech, and the core would run as Kotlin/JS or Wasm with a new SQLite bridge. That’s a third UI stack with no reuse. Not recommended.
- Electron: heavy, the opposite of the goal. Rejected.
- Compose on Kotlin/Native for desktop (no JVM at all): Compose Multiplatform has no supported Kotlin/Native desktop target, so this isn’t available.
E. A Rust UI toolkit with the Kotlin/Native core behind a C ABI
Analysis only (2026-10-08), nothing was built. Candidates are Slint, egui, iced, Dioxus, Xilem and GPUI. Kotlin/Native would export the shared logic as a static library with a C header, and the Rust side would call it (or the reverse).
- Cost: high. It needs
macosArm64,linuxX64andmingwX64targets insharedLogic(to check against SQLDelight and our dependencies), a hand-written flat C API (no coroutines, Flow or generics across the boundary, manual ownership of strings and lists), and Gradle, Cargo and a C toolchain on every CI OS. It is a fourth UI stack with no reuse of the Android Compose screens. - Editor: the Rust toolkits have weak rich text editing and accessibility. Jotry’s editor would be custom work, probably worse than a platform text view or Compose’s.
- Upside: small binary, fast start, no JVM or Skia, no GraalVM closed-world limits, no dependence on Nucleus.
- Verdict: not recommended. Revisit only if native image misses the pass bar and all three OSes still matter.
F. Compose on JetBrains’ kotlin-desktop-toolkit (KDT)
kotlin-desktop-toolkit wraps native windowing in a Kotlin API and offers Skia-compatible rendering surfaces. Status read from its README on 2026-10-08, not tried:
- It is labelled an internal JetBrains project, has no releases, no published Maven artifacts and about 200 stars. It is the foundation of JetBrains’ Air framework. Its README says it “will later” provide the OS integration layer for Compose for Desktop, so Compose integration is planned, not delivered. A third-party summary says its native layer is Rust (not verified).
- Platform status: macOS is the most complete. Windows has no application menu, no Vulkan, and file dialogs are in progress. Linux X11 is decent, Wayland has no application menu. System tray and accessibility are missing everywhere, and custom text input is missing on Windows.
- Not usable for Jotry today. Accessibility and text input matter for a notes editor.
- Switching later:
desktopUiis plain Compose with no Nucleus dependency, so the screens, state,EditorSessionand tests carry over. Only the window shell (desktopApp, about 40 lines of Kotlin plus Gradle configuration) would be replaced. That is a few days of work on menus, tray and packaging. Going to KDT from Nucleus loses native image, the updater and the packaging formats until KDT or the official plugin covers them. Going from the plain JVM plugin (option A) is cheaper, because KDT may arrive under Compose with a version bump. - Plan: don’t build on it. Keep
desktopUifree of Nucleus and KDT. Re-check KDT releases and the Compose Multiplatform changelog every few months, and revisit when a Compose-on-KDT artifact exists.
Answers to the questions asked
Is Nucleus good for Jotry? It’s the most complete way to get a Compose desktop app that feels native and starts fast: native windows and menus, installers, auto-update and native image in one plugin. The costs are dependence on a small project, an MCP server that can’t see inside its windows, and no Swing interop (which limits editor options). It’s worth a spike, not a commitment. Keep the UI as plain Compose so that dropping back to the official plugin (option A) stays a build change, not a rewrite.
Is desktop UI testing easier with Compose? Yes, much easier than Espresso or XCUITest:
runComposeUiTest(or the JUnitcreateComposeRule) runs in-process on the JVM. There’s no emulator or simulator, it renders offscreen, and tests take milliseconds to seconds. The finders and assertions are the same semantics API as our Android Compose tests.- Roborazzi, which we already use on Android, supports desktop screenshot tests.
- Limits: these tests cover composables, not the native window shell. Nucleus’s tao window, native menu bar, tray, file dialogs and the native-image binary need a few separate smoke tests. Headless Linux CI may need
xvfb. The desktop test API is still marked experimental. - If we share screens between Android and desktop in a Compose Multiplatform module, many UI tests can be written once in
commonTest. Today the Android UI tests are Android-only.
Can an agent run and debug the app?
- Compose Hot Reload MCP server (experimental, Hot Reload 1.2 / Compose Multiplatform 1.12): run the
hotMcpServerGradle task and point Claude Code at it in.mcp.json. It can reload code, restart the app or reset the UI, and read logs and UI exceptions. Community write-ups also list screenshots, reading the semantics tree, clicking and typing. Reports show rough edges: a log line on stdout breaking JSON-RPC, and Claude Code failing to connect. It works only on the JVM, in development, not on a native-image build. With Nucleus’s tao window, reload, restart, logs and errors work, but screenshots, the semantics tree, clicking and typing time out (spike result). - A small in-app test hook we control: a debug-only local endpoint (or a stdio MCP that’s part of the app in debug builds) that dumps the semantics tree and performs actions through the same API the UI tests use. This is more work, but it works for any backend and doesn’t depend on hot reload. It must never ship in release builds.
- OS accessibility tools (computer use, AX-tree MCPs) work on any option, including native image and option C, if the app exposes accessibility correctly. Nucleus says its accessibility layer is checked against macOS AX, UI Automation and AT-SPI in CI.
- Debugging: a full JVM debugger on JVM runs. A native-image binary can only be debugged with gdb/lldb and debug info, so reproduce bugs on the JVM build.
Is GraalVM native image the way to “fast and lightweight”? For a Compose app, it’s the biggest available win: much faster cold start, and roughly half the memory of the JVM. It doesn’t shrink the app much (Skia is large), and it adds build complexity. Only option C (native macOS) beats it, and only on the Mac.
Recommendation (to confirm)
Decide the platform priority first.Decided by the user on 2026-10-07: Linux, macOS and Windows together, so option B with option A as the fallback.- Spike before any D-xx. Done on macOS (see spike results). The original brief was: Measure Nucleus with native image against the JVM on a real Jotry slice, not Hello World:
- Build:
NotesCorewith SQLite (and SQLCipher, if encryption is in scope by then), a notebook list, a notes list, and aBasicTextFieldeditor wired toEditorSessionwith bold and a checklist. - Measure on the Mac (and on Windows if it’s in scope): cold start to first frame, idle RSS, RSS with 10k notes, typing latency, app size, and native build time in CI.
- Check: hot reload and the hot-reload MCP under the Nucleus tao backend,
runComposeUiTestplus Roborazzi in CI, and whethersqlite-jdbcworks in native image. - Pass bar (proposal): cold start under 0.5 s, and idle RSS under 150 MB with 10k notes. Result on macOS: the native start passes (~235 ms). Memory passes only with a heap cap (142 MB with
-Xmx64m, 195 MB without one), and paging the notes list would help more. Linux and Windows are still to run.
- Build:
- Shared Compose UI is a separate decision. If desktop uses Compose, moving Android screens into a shared Compose Multiplatform module would give one UI and one set of UI tests for Android and desktop. It touches every Android screen, and it needs a desktop editor that matches D-12’s behavior.
Decisions needed
Which desktop platforms come first?All three (the user, 2026-10-07). This still needs to be recorded as a D-xx that refines D-01 and D-07.- Nucleus (B) or the official plugin (A)? Agent access is the main trade-off: the hot-reload MCP server fully supports only stock windows.
- The desktop editor:
BasicTextFieldadapter, or something else? This needs a D-xx that extends D-12. - Native image or JVM for release builds, based on the spike numbers.
- Whether Android screens move to a shared Compose Multiplatform module.
- Rough reuse estimate (2026-10-08, from file sizes, not checked import by import):
app/androidApp/src/mainis about 4.4k lines in 18 Kotlin files. Roughly 2.5–3k lines (theme, formatting, components, bars, drawer and the notes, search, trash, history and settings screens) could be shared after replacing Android resources,Context/Intentand file-picker calls and theandroidx.lifecycleViewModel. About 1k lines cannot:NoteEditor(601 lines),RichTextContent,MainActivity,JotryApplicationand Gemini Nano titles. Desktop also needs its own layout (sidebar, menu bar, shortcuts), not the phone layout.
- Rough reuse estimate (2026-10-08, from file sizes, not checked import by import):
- Desktop database encryption, the driver, and the minimum OS versions.
Sources
- Nucleus on GitHub and docs: architecture, runtimes, GraalVM, benchmarks
- Nucleus GraalVM maintainer post (Kotlin Slack) and the Nucleus 2.0 thread with the hot reload report
- Testing Compose Multiplatform UI and desktop UI testing with JUnit
- Compose Hot Reload 1.2.0 release, InfoWorld on Compose Multiplatform 1.12 and MCP, Slack: hot reload MCP with Claude, Slack: getting the MCP server running
- Slack: Compose desktop and native image
Folder sync and Markdown interoperability (design plan)
Status: future plan, not a decision. This is a proposal for the folder-sync phase (Phase 3 in
04-roadmap.md) and for the Markdown file format that export and folder sync share. The only parts that are agreed are recorded in05-decisions-and-open-questions.md: D-06 (Android local-folder sync is the first sync extension), D-34 (folder sync and export are never paid features), D-35 (folder sync carries current notes only; full history is a hosted-sync feature) and D-36 (note links are exported as relative Markdown links). Everything else below is open until it is recorded as a new D-xx.
Goal
Let people who use other Markdown tools (Obsidian, VS Code, iA Writer, Typora, git, Syncthing, iCloud Drive) keep a folder of their Jotry notes that those tools can read and edit, with changes flowing both ways, without Jotry giving up its own guarantees: every change is a revision, nothing is silently lost, and identity survives renames.
For most users this feature is invisible. It lives under Settings → Advanced and needs a deliberate choice of folder. People who never open it are not affected.
How Obsidian does it (reference)
Obsidian is file-first: the folder (a “vault”) is the only source of truth.
- Identity is the file path. There are no note IDs. The
.obsidian/metadata cache is derived and can be rebuilt. - When a file is renamed inside Obsidian, it rewrites every link that points to it. A rename done in Finder or another app looks like a delete plus a create, and links to it break.
- Links default to
[[wikilinks]]resolved by the shortest unique path. Users can switch to standard Markdown links with relative paths (“Use [[Wikilinks]]” off, “New link format: Relative path to file”). People who care about interoperability usually do. - Front matter is optional. Obsidian shows it as “Properties”.
- Obsidian does not move files between devices itself: its paid Sync service, iCloud, Syncthing or git do. Obsidian watches the folder and reloads changes. Conflict handling belongs to the sync tool (often a “conflicted copy” file); Obsidian Sync tries to merge text.
- History is a bolt-on: the File Recovery plugin keeps local snapshots, and Sync keeps version history.
Jotry is the opposite: database-first, ID-based identity, immutable revision history. Once another app can edit the folder, the folder is a second source of truth, and folder sync is a reconciliation problem, not an export problem. Jotry’s history model is the advantage here: an external change becomes an import revision, so nothing is overwritten.
(The Obsidian description is from general knowledge, not checked against its current documentation. Verify before relying on details such as setting names.)
Principles
- The database stays the source of truth for identity and history. The folder holds current notes only (D-35).
- Files look native. Human file names, standard Markdown, standard relative links, no required metadata block. A file written by Jotry should look like one written by hand.
- Never lose either side. If both Jotry and the folder changed a note, both versions are kept.
- Simple by default. One folder, one switch. Format options exist but are not needed.
- The same file format as export. Export is a one-shot, one-way folder sync. Both use the same writer, reader and link translator.
1. File format (shared with export)
Layout
<chosen folder>/
.jotry/ managed-folder marker and format version (see "Managed folder marker")
Q3 Plan.md notes in the default notebook (D-15) sit at the root
Work/
Standup notes.md
Q3 Plan 2.md a second "Q3 Plan" in the same notebook
Personal/
Ideas.md
- Folder = notebook. Notebooks cannot nest (D-15), so only one folder level is used. Deeper folders created by other apps: see open question Q4.
- The default notebook is the root folder. This matches what the user sees at the top level.
- Archived notebooks: exported like any other notebook. Whether they also sync is open (Q9).
File names
- File name = note title, kept as typed: case, spaces, accents and most punctuation stay. Only characters that are unsafe on common file systems are replaced (
/ \ : * ? " < > |, control characters, leading dots, trailing dots and spaces), and the name is capped at a safe length (for example 120 UTF-8 bytes before.md). - A blank automatic title (D-23) uses the display title, otherwise “Untitled”.
- Collisions inside one folder get
2,3… (Obsidian’s style), assigned by note creation order so they stay stable. - No
--<uuid>suffix. Identity lives in the database (section 2), not in the name. - The title comes back from the file name on import. Because names keep case and most punctuation, the round trip is lossless except for replaced characters.
Body
- The note’s Markdown exactly as stored, UTF-8,
\nline endings, ending in a newline. - No front matter by default. Front matter is not needed when the database holds identity (section 2), and files without it look native in every editor. Open question Q1 covers an opt-in “Write Jotry properties” setting.
- Front matter in files Jotry reads is preserved, never stripped. Front matter written by another app (Obsidian properties, tags, aliases) is kept byte-for-byte when Jotry rewrites the file. Jotry does not show or edit it in Release 1 of this feature (Q2).
Note links (D-36)
Inside the app, links stay jotry://note/<noteId> (D-25). At the file boundary they are translated:
- Writing:
[label](jotry://note/<id>)becomes[label](<relative path>), for example[Ideas](../Personal/Ideas.md). Spaces and reserved characters are percent-encoded (Q3%20Plan.md), which Obsidian, VS Code, GitHub and most renderers understand. A link to a note that isn’t in the folder (trashed, or in an excluded notebook) is written as its plain label, keeping the text readable. - Reading: a relative
.mdlink that resolves to a mapped file becomesjotry://note/<id>.[[Wikilinks]](with optional|labeland#heading) are also accepted and resolved Obsidian’s way: exact path first, then the unique file with that name anywhere in the folder. Ambiguous or unresolved links are left untouched as text so nothing is lost. - Renames inside Jotry: when a note’s title or notebook changes, Jotry renames the file and rewrites links to it in the other managed files, as Obsidian does. Each rewrite is a normal file write. It does not create revisions of the linking notes, because their stored Markdown (
jotry://links) has not changed.
The same translator is used by export (D-36), so export can ship first.
2. Identity and the sync map
A local table in the shared core holds the link between notes and files. The core owns it; the extension never reads the notes database directly.
| Column | Meaning |
|---|---|
noteId | The note |
relativePath | Current path inside the folder |
providerRef | Platform handle when the provider has one (SAF document URI, iOS bookmark-relative URL) |
syncedRevisionId | The note revision last written to or read from the file (the common ancestor for reconcile) |
syncedContentHash | Hash of the file bytes at that point |
syncedSize, syncedModifiedAt | A cheap “may have changed” check before hashing |
Doc 03 already says: keep a local mapping of note ID to provider document, and do not depend on file names as identity. This table is that mapping.
Rename detection for changes made in other apps. In one scan, if a mapped file is missing and an unmapped file appears whose content hash equals the missing file’s last synced hash (or is very similar, with a clear threshold), treat it as a rename or move. Update relativePath and, if the folder changed, move the note to the matching notebook. Otherwise the missing file is a possible deletion (section 3) and the new file is a new note.
3. Reconcile (three-way)
Each scan compares, for every mapped note, the file now, the note now and the synced point (syncedRevisionId / syncedContentHash).
| File | Note in Jotry | Action |
|---|---|---|
| unchanged | unchanged | nothing |
| changed | unchanged | import the file as a new revision with reason IMPORT (origin: folder); update the map |
| unchanged | changed | write the file (temporary file plus rename where the provider supports it, otherwise verify the hash after writing); update the map |
| changed | changed | conflict: keep Jotry’s version current, record the file’s version as a revision on a side branch, mark the note as having a conflict, and rewrite the file from Jotry’s version only after the user resolves it (Q5) |
| missing | any | possible deletion: do nothing destructive. After the deletion checks (the folder is reachable, the scan completed, the file is still missing on a second scan), move the note to Trash with Undo and an entry in the sync status. Never purge. |
| new, unmapped | — | create a note in the notebook named by its folder (create the notebook if needed), add it to the map |
Rules that apply throughout:
- Only Jotry’s own writes, imports and moves touch notes. Every change goes through the normal transactional revision path (D-04).
- Imported changes respect D-14: imports always add a version.
- A file that cannot be parsed or decoded (not UTF-8, binary) is skipped with a warning. It is never deleted or rewritten.
- Files that aren’t Markdown are left alone and never deleted (attachments: Q7).
- Every step is idempotent, so an interrupted scan can simply run again.
4. Platforms
| Platform | Folder access | When sync runs | Proposed availability |
|---|---|---|---|
| Android | Storage Access Framework tree URI with a persistable permission | On app open, after saves (debounced), and periodic WorkManager jobs; background is best-effort | Yes, under Settings → Advanced (the first target, D-06) |
| iOS | Folder chosen in the Files picker, kept as a security-scoped bookmark; NSFileCoordinator for iCloud Drive | Only while the app is open: on open, on foreground, after saves. Background folder access is not reliable on iOS. | Proposed: not in the first version, or only as a manual “Sync now” (Q8) |
| Desktop (future, D-07) | Ordinary file system path with a file watcher | Continuously, while the app runs | The best home for folder sync: Obsidian, VS Code and git are on the same machine |
On desktop, the flow for power users becomes: phone ⇄ hosted sync ⇄ desktop app ⇄ folder ⇄ Obsidian / VS Code / git. Phones stay simple, and the desktop app is the bridge to other tools.
Large folders: compare size and modification date first and hash only when they differ, so repeated scans stay fast.
5. Core vs extension
The split follows doc 03 (destinations declare capabilities; extensions are built into the app, not downloaded plugins, which iOS does not allow anyway).
Shared core (correctness, one implementation):
- the file format writer and reader, including the link translator (also used by export)
- the sync map, three-way reconcile, rename detection and deletion checks
- turning file changes into revisions, and conflict branches
- the status model (pending, last success, warnings, actions needed)
Folder extension (transport, per platform):
- choosing the folder and keeping access (SAF permission, iOS bookmark, desktop path)
- listing, reading and writing files, atomic replace where available
- change detection and scheduling (WorkManager, foreground triggers, desktop watcher)
- declaring its capabilities:
bidirectional; noremoteHistory;stableItemIdsonly through the sync map;atomicWriteandchangeTokensdepending on the provider
The same core is reused by hosted sync (see monetization-and-sync-tiers.md), which adds history transport on top.
6. Managed folder marker
Doc 03 proposes a manifest file in the folder root. Proposed shape: a .jotry/ folder holding folder.json with the format version, the device that set the folder up and the date. Its purposes:
- recognize a folder Jotry already manages (re-selecting it after reinstalling the app, or from a second device)
- refuse to manage a folder written by a newer, unknown format version
- avoid treating a folder another tool owns as Jotry’s
It holds no note data. A dot-folder is hidden by Obsidian and most file browsers.
7. User experience
- Settings → Advanced → Folder sync: “Choose folder”, then a status line (“Synced 2 minutes ago”, “3 changes waiting”, “1 conflict”). Turning it off stops sync and leaves the files where they are.
- Before turning it on, one short explanation: the folder holds your current notes; version history stays in the app; other apps can edit these files and Jotry will pick up their changes.
- A non-empty folder that isn’t managed yet: offer to import its Markdown files (the existing import summary) and then manage it.
- Conflicts appear on the note (a small banner) and in History, which already shows branches and restore. Choosing a version is a normal restore.
- Optional format settings (only if Q1/Q3 need them): link style for new links (relative or wikilink), “Write Jotry properties to files”.
- A short guide for Obsidian users: turn off wikilinks and choose relative paths, so links written in Obsidian also work in Jotry.
8. Export changes that fall out of this
Export becomes “write the folder format once”, so it gains the same benefits before folder sync exists:
- Relative note links (D-36), using the shared translator.
- Human file names, as in section 1. Decided for export by D-42.
The front-matter question (Q1) applies to export too.Decided for export by D-42: export always writes front matter (note_idand the rest), because an export folder has no sync map to recognize its files on re-import. Q1 remains open for folder sync only.
Import keeps reading today’s exports (front matter with note_id, --<uuid> file names), so old exports keep working.
9. Test plan (when implemented)
- Round trip: export → import into an empty app gives identical notes, notebooks and links.
- Obsidian-style edits: rename in another app, move between folders, add a wikilink, add front matter properties; each lands as the expected revision, with properties preserved.
- Conflicts: edit the same note in Jotry and in the folder between scans; both versions survive.
- Deletion safety: an unreachable folder, a partial scan, a revoked permission, and a folder emptied by a sync tool; none of these trash notes on the first scan.
- Collisions and unsafe names: titles that differ only in case, titles with
/or:, very long titles, accented characters (Unicode normalization on macOS and iOS). - Interruptions: kill the app mid-write and mid-scan; the next run converges and creates no duplicates.
- Scale: a folder with thousands of notes; repeated scans stay fast.
Open questions
| # | Question | Proposed direction |
|---|---|---|
| Q1 | Do files carry any Jotry metadata (front matter note_id)? | No by default; the sync map holds identity. Offer an opt-in “Write Jotry properties” for people who move files around outside the app, and for exports meant for re-import. |
| Q2 | Does Jotry show or edit front matter written by other apps? | Preserve it exactly, don’t show it in the first version. Revisit with properties/metadata views (doc 06). |
| Q3 | Which link style does Jotry write to files? | Relative Markdown links only (D-36). Read wikilinks, but don’t write them. |
| Q4 | Folders nested deeper than one level, created by other apps? | Map the top-level folder to the notebook and keep the subpath as part of the file’s path, or flatten with a warning. Needs a decision before implementation, because notebooks cannot nest (D-15). |
| Q5 | How is a conflict resolved in the UI? | Reuse History: both versions are visible, restoring either one resolves it. A side-by-side merge view can come later. |
| Q6 | When does a missing file become a deletion? | After two complete scans that both see it missing while the folder is reachable; then Trash with Undo, never a purge. |
| Q7 | Images and other attachments? | Out of scope until Jotry has attachments. Leave non-Markdown files alone. |
| Q8 | Folder sync on iOS? | Not in the first version, or a manual “Sync now” only. Revisit after desktop. |
| Q9 | Do archived notebooks sync? | Yes, as normal folders; archiving is an app-side view state. |
| Q10 | Which folder providers are supported (from doc 05)? | Local storage first; iCloud Drive, Google Drive and Syncthing folders marked experimental until tested. |
| Q11 | Can two Jotry devices manage the same folder (for example over Syncthing)? | Allowed but not promoted: each device reconciles through its own map, so it works, but hosted sync is the supported way to sync Jotry devices. The managed-folder marker should detect it and explain this. |
| Q12 | Exact file-name rules (length cap, Unicode normalization, reserved Windows names)? | Length cap (120 UTF-8 bytes) and NFC normalization decided by D-42 for export. Reserved Windows names: decide with the test matrix in section 9. |
Paid features, sync tiers and store release (design plan)
Status: future plan, not a decision. This explores how Jotry could be sold on the App Store and Google Play, and how that shapes sync. Agreed so far (see
05-decisions-and-open-questions.md): D-33 (hosted sync runs on Jotry’s own end-to-end encrypted server), D-34 (export and folder sync are never paid features), D-35 (hosted sync carries the full revision history; folder sync carries current notes only). Pricing, the business model and everything else below are open. Update 2026-10-08: D-45 makes hosted sync personal for now, on the owner’s backend infrastructure; whether that runs an own Ktor server or a self-hosted Supabase is still open. Update 2026-10-09: the billing model is now agreed: D-52 (entitlement belongs to the sync group; several stores can contribute; latest expiry wins), D-53 (a free app with a sync subscription bought in the stores; the purchase creates the group), D-54 (purchase bound to the group by the store’s account token), D-56 (lapsed groups are read-only and kept for a year) and D-58 (pluggable store verifier; promo entitlements possible). Prices, trial length and the rest remain open. Related plans:cloud-sync-and-e2ee.md(server, crypto, keys) andfolder-sync.md(folder sync and the file format).
Starting points
- Folder sync is effectively free multi-device sync. Point Jotry at an iCloud Drive or Syncthing folder on two devices and they sync. A paywall on folder sync would not protect much revenue, and would break the “your data stays yours” promise in the README. Obsidian is in the same position (free app, paid Sync competing with free iCloud and git) and still sells Sync, because people pay for “it just works”, history and encryption, not for the bare ability to move files.
- What people pay for is convenience and safety: one switch, no folder setup, full history on every device, end-to-end encryption, and it works between an iPhone and an Android phone.
- Using the app should stay simple. Power features exist but don’t get in the way of people who never need them.
Proposed tiers
| Tier | Contents |
|---|---|
| Free | The whole app on any number of devices without syncing them: notes, notebooks, history, search, links, export, import. Folder sync where the platform supports it (Android, desktop). |
| Paid (subscription) | Hosted sync: end-to-end encrypted, across devices and across platforms, with the full revision history on every device (D-35). Device pairing by QR code. Recovery key. |
Why a subscription for sync: it has an ongoing cost (server, storage, support), and stores and users both understand “service = subscription”. A one-time “Pro” purchase only makes sense for local-only extras, and there are none planned.
Why not a paid app: an upfront price hurts discovery, and the free app is the marketing. People who try it and want sync convert; people who don’t still recommend it.
Principles to record (proposed)
- Export is never paywalled. (Recorded as D-34, together with folder sync.)
- Local features never need an account or a network connection.
- The paid check lives in one place: the hosted-sync transport. The rest of the app never asks “is this user paying?”
- Cancelling never locks anyone out of their notes. If a subscription ends, sync stops; every device keeps its notes and history, and export still works.
Sync architecture across tiers
One sync core, several transports (see doc 03’s destination capability contract):
shared sync core
(identity map, reconcile, revisions, conflicts, link translation)
| | |
folder transport hosted transport (later: WebDAV, git…)
free, current notes paid, full history
Android + desktop all platforms
- Hosted sync moves the revision graph (records, as in
cloud-sync-and-e2ee.mdsection 2), not files. That is the honest difference between the tiers (D-35). - The entitlement check is one gate in front of the hosted transport.
Hosted sync: own end-to-end server (D-33)
The backend is Jotry’s own server (the Ktor server/ module), not a managed backend such as Supabase or Firebase and not the platform stores (CloudKit, Google Drive app data). Platform stores can’t sync an iPhone with an Android phone; a managed backend adds a third party that sees metadata. The cost: we run, secure and pay for a server.
The server is a dumb store for encrypted records and only sees ciphertext; the design is in cloud-sync-and-e2ee.md sections 3–5.
Accounts or no accounts
The README promises “no account”. Hosted sync needs some identity, so two options:
| Option | How it works | Trade-off |
|---|---|---|
| A. Sync group, no account (proposed) | The first device creates a sync group and its root key. Other devices join by scanning a QR code on a device that is already in the group (the device-to-device enrollment in cloud-sync-and-e2ee.md section 4, option C). A recovery key is the fallback if every device is lost. No email, no password. | The README promise survives (“no account; we can’t read your notes”). Support can’t “reset your password”. Losing every device and the recovery key loses the hosted copy, which must be said plainly. |
| B. Account (email, passkey) | Normal sign-in for authentication only; encryption keys stay separate. | Familiar, and makes subscription restore and support easier. Breaks the “no account” promise, and the server now holds an email address. |
Chosen: A (D-45), with the store purchase tied to the group (D-52 to D-54).
Store rules and licensing
(Store policies change; check the current App Store Review Guidelines and Google Play policies before building.)
- In-app purchase: unlocking features inside the app generally has to use Apple’s and Google’s billing. Some regions now allow outside payment links or other billing under conditions; treat that as optional, not as the plan.
- No downloaded code: Apple does not allow apps to download and run new code. Extensions (folder sync, hosted sync, future ones) are built into the app, as doc 03 already assumes.
- Cross-platform licensing (D-52 to D-54): a store purchase is tied to an Apple ID or a Google account, so buying on iPhone does not unlock Android by itself. The client sends the store’s signed transaction to the server together with the group it belongs to; the account token inside the transaction names that group, so a leaked receipt cannot be claimed by another group. The server verifies it and attaches it to the group as an entitlement, and every device in the group syncs. A group can hold entitlements from several stores, and the latest expiry wins.
- Switching platforms: a new iPhone joins the group by QR from the old Android phone and needs no purchase; billing stays with Google until the user cancels there. To move billing, they buy the Apple subscription for the same group; the group then has two entitlements and the later expiry applies. The app shows which store bills the group and links to that store’s subscription page. The server cannot cancel across stores.
- Apple’s multiplatform rule: Apple lets an app grant access to a service bought elsewhere only if the same purchase is also offered in the iOS app (guideline 3.1.3(b) as we read it; re-check the current wording before submission).
- Lost phone, same store account: the recovery key restores the group (D-55). Restoring the purchase on the new phone reattaches a lapsed group within the year it is kept (D-56). There is no cross-store proof of ownership: Apple cannot verify a Google purchase or the reverse.
- Before the store accounts exist: the server’s entitlement check is a pluggable verifier (D-58). Sync, enrollment and recovery are built against a development verifier; Apple’s local StoreKit testing and recorded signed transactions cover the Apple verifier. Sandbox purchases, TestFlight and Play Billing need paid developer accounts; Google may also require a closed test with a minimum number of testers before production, so open that account early.
- Family sharing, refunds, grace periods, billing retry: the server must handle the store notifications for each platform.
- Privacy disclosures: hosted sync changes the App Store privacy label and the Google Play data safety form (encrypted content, device identifiers, purchase records). Decide what we collect before writing them.
- README promises to update when sync ships: “no Jotry cloud service” becomes “an optional, end-to-end encrypted sync service; no account; we can’t read your notes”.
Simple by default, power when wanted
| Who | What they see |
|---|---|
| Everyone | The app works offline on one device with nothing to set up. Export is one tap in Settings. |
| Someone who wants their notes on two devices | One switch, “Sync across devices”, and a QR code to add a device. No folders, formats or links. |
| Power users | Settings → Advanced: folder sync, link format, “write Jotry properties to files”. Not shown unless opened. |
Folder sync is strongest on desktop (file watchers, background running, Obsidian/VS Code/git on the same machine), works acceptably on Android, and is fragile on iOS. Proposal: Android and desktop get folder sync; iOS gets hosted sync, and folder sync only as a manual “Sync now” if at all (see folder-sync.md Q8).
A desktop app is a separate product to build and support (D-07 keeps it a later phase). The repo’s webApp is not a substitute: a browser can’t keep a folder in sync.
Formats become a contract at release
Once people have folders and exports in the wild, the file format is something other apps and users depend on. Decide these before the first public release:
- relative links in exported files (D-36, decided)
- file names and the front-matter default (
folder-sync.mdQ1) - a format version in the managed-folder marker and in
EXPORT-INFO.txt
The hosted-sync record format is versioned from the first implementation, as doc 03 already requires.
Suggested order
- Export: relative links (D-36) and human file names. Small; builds the shared file-format code.
- Release 1 ships with the export format settled.
- Sync core (identity map, reconcile, conflicts), then Android folder sync (free; D-06).
- Server, crypto, QR pairing, recovery key, then hosted sync on Android and iOS (paid).
- Store billing and server-side receipt checks, tied to the sync group.
- Desktop app with folder sync and hosted sync.
Open questions
| # | Question | Proposed direction |
|---|---|---|
| Resolved by D-53: free app with a hosted-sync subscription. | ||
| M2 | Price, trial length, and whether there is a yearly plan. | Decide closer to release, after the beta. |
| Resolved by D-45: account-free sync groups with QR pairing and a recovery key (option A). | ||
| Resolved by D-52 and D-54: the server verifies store transactions and attaches each to the sync group. | ||
| Resolved by D-56: the group becomes read-only and the server keeps it for a year, then deletes it. Devices keep everything locally. | ||
| M6 | Storage limits on hosted sync (history can grow)? | Generous fixed limit per group; show usage; revisit with the revision-retention question in doc 05. |
| M7 | Is the server self-hostable for users who don’t want ours? | Desirable for the privacy brand; decide after the protocol is stable. Self-hosting would bypass billing, so it would be a deliberate choice. |
| M8 | Is folder sync on iOS worth building? | Probably not at first; see folder-sync.md Q8. |
| M9 | When does a desktop app happen, and on which technology? | After hosted sync; Kotlin Multiplatform with Compose Desktop is the natural first candidate (D-07). |
| M10 | Which metadata is collected for billing and abuse prevention, and how is it disclosed? | The minimum the stores require; document it with the threat model in cloud-sync-and-e2ee.md. |
Sync system design: enrollment, auth, encryption at rest and syncing
Status: design proposal, not a decision. It builds on the decisions D-27 (E2EE for anything that leaves the device), D-45 (account-free sync groups on the backend infrastructure), D-46 (the server’s own Postgres), D-47 (libsodium, XChaCha20-Poly1305, per-note data keys wrapped under the root key), D-48 (QR enrollment plus a 24-word recovery key), D-49 (ask the user about delete-versus-edit conflicts), D-50 (no size padding) D-51 (desktop key storage), D-52 to D-58 (entitlement per sync group, store subscriptions, recovery key derivation, one-year retention of lapsed groups, optional keychain backup, a pluggable store verifier), D-64 (a new note key for each rotation, formats version 2), D-74 (a long-term X25519 encryption key per device) and D-75 (how the root key is rotated and where its recovery copy is sealed). Everything else here is a proposal until a
D-xxrecords it. Background and options are in cloud-sync-and-e2ee.md.Built so far: the
:cryptomodule (crypto/), with the root key, note key wrapping, record envelopes and the QR enrollment wrap; the server’s auth, enrollment, recovery and record routes; the client sync engine (app/sharedLogic/.../sync/SyncEngine.kt: the outbox, push, pull and merging of section 7) withKtorDestinationandNotesCore.sync, tested against a fake server and the real one; and root key rotation (D-75: the server’s rotation routes, the engine’s key rewrite andCloudSync’s flow). In the apps, Settings can turn sync on (a promo code, the recovery key shown once and three of its words typed back;CloudSync.turnOn), join a group by QR code (CloudSync.startJoining,finishJoiningandapprove, D-77), and shows its status with Sync now, Add a device and turning it off; removing devices, the devices list, recovery and the D-57 keychain backup are not built.
1. The short version
- The server is a dumb store of ciphertext. It knows which devices belong to which group, and it orders changes, but it can’t read a note, a title or a notebook name.
- Two kinds of secrets, kept apart. Encryption keys (root key, note keys) decide who can read. Device signing keys decide who can talk to the server. Holding one never gives the other.
- Devices join by QR code. A device already in the group scans the new device’s QR code and hands it the root key, encrypted to the new device. The server only relays it.
- Sync is push and pull of sealed records. Each device keeps an outbox of local changes, pushes them with a conditional put, and pulls everyone else’s changes from a per-group change feed.
2. Components
flowchart LR
subgraph Devices
A["Android app<br/>NotesCore + SQLite"]
I["iOS app<br/>NotesCore + SQLite"]
D["Desktop app<br/>NotesCore + SQLite"]
end
subgraph Shared["Shared Kotlin (all clients)"]
C[":crypto<br/>libsodium"]
S["Sync engine<br/>outbox, cursor, merge"]
end
subgraph Box["Backend infrastructure"]
CA["HTTPS proxy"]
K["Ktor sync server<br/>auth, records, change feed"]
P[("Postgres<br/>own instance, D-46")]
end
A & I & D --- S
S --- C
S -- "HTTPS" --> CA
CA --> K
K --> P
The proxy terminates HTTPS; the Ktor server checks device signatures and the group’s entitlement, and stores records. The app never relies on the transport for confidentiality, because everything it sends is already encrypted.
3. Keys
flowchart TD
RC["Recovery key, 24 words<br/>(shown once, kept by the user)"]
RK["Root key RK<br/>32 bytes, one per group, has an id"]
NK["Note keys NK<br/>32 bytes, random, numbered 1, 2, ...<br/>a new one at each rotation"]
REC["Records<br/>note metadata, each revision"]
DX["Device enrollment key<br/>X25519, temporary"]
DS["Device signing key<br/>Ed25519, one per device"]
SRV["Sync server"]
RC -- "wraps (backup copy on server)" --> RK
DX -- "receives RK once, at enrollment" --> RK
RK -- "wraps (82 bytes per key)" --> NK
NK -- "seals (XChaCha20-Poly1305)" --> REC
DS -- "signs requests, proves membership" --> SRV
| Key | Where it lives | What it protects | Lifetime |
|---|---|---|---|
| Root key (RK) | Each device, wrapped by Android Keystore / iOS Keychain (ThisDeviceOnly); on desktop by macOS Keychain, Windows DPAPI or Linux Secret Service, or a passphrase where none is available (D-51). A copy sealed to the recovery key’s X25519 key on the server (D-75). A device keeps the earlier ones too (D-75). | Wraps note keys | Until rotated (device removed) |
| Note keys (NK) | A list per note, each wrapped by RK, stored on the server with the note and in the local database. Unwrapped only in memory. | NK n seals the records written between rotation n and the next | The note’s life; a new one at every rotation (D-64) |
| Device signing key | Each device, in the same store as the root key. Public half on the server. | Proves to the server that a request comes from a member device | Until the device is removed |
| Device encryption key (X25519, D-74) | Each device, in the same store. Public half on the server, covered by the statements members sign. | Receives RK at enrollment and after each rotation | Until the device is removed |
| Recovery key | 24 words with the user (printed, password manager), shown once when sync is turned on (D-48) | Derives the lookup id, a signing key and an X25519 key; the last opens the server’s copy of RK (D-75) | Until regenerated |
Why per-note keys under a root key (D-47). Rotating the root key, for example after removing a lost phone, rewraps the note keys (82 bytes each) and the records themselves never need re-encrypting. Per-note keys also leave room for sharing a single note later without exposing the rest of the group.
Formats (version 2, all built)
| Item | Layout | Associated data |
|---|---|---|
| Wrapped note key (82 B) | version, alg, rootKeyId(4), noteKeyId(4), nonce(24), sealed NK(32), tag(16) | label, header, note id |
| Record envelope | version, alg, noteKeyId(4), nonce(24), ciphertext, tag(16) | label, header, record id, caller context (schema version, content hash) |
| Wrapped root key for enrollment (110 B) | version, alg, rootKeyId(4), ephemeral X25519 key(32), nonce(24), sealed RK(32), tag(16) | label, header, recipient’s public key, group id |
| Root key sealed for recovery (110 B, D-75) | the enrollment wrap, to the recovery key’s X25519 public key | label, header, recipient’s public key, group id |
The enrollment wrap is still version 1, and is also what a rotation sends to each device and what the server keeps for recovery (D-74, D-75; the recovery copy was a symmetric 78-byte wrap before D-75). The wrapped note key and the record envelope moved to version 2 to carry the note key id (D-64), which the associated data covers so the server cannot relabel a key or a record. Version 1 of those two never shipped. All use XChaCha20-Poly1305 from libsodium with random 24-byte nonces. The enrollment wrapping key is BLAKE2b-256 over the X25519 shared secret and both public keys, as libsodium recommends. The associated data stops the server from moving a ciphertext to another note, record or group, or replaying it with other metadata.
4. Auth: who may talk to the server
There are no accounts, emails or passwords. Membership is a list of device public keys per group, and the server trusts a new key only when an existing member vouches for it.
Group creation. The first device makes a random 128-bit group id, a root key and its own Ed25519 signing key, and registers the group with its public key. From then on it is the group’s first member.
Getting a session. A device asks for a challenge, signs it, and gets an opaque bearer token valid for 15 minutes (D-67). The server stores only its SHA-256, so any server container can check any token against the shared Postgres, and removing a device deletes its tokens at once.
sequenceDiagram
participant Dev as Device
participant Srv as Sync server
participant DB as Postgres
Dev->>Srv: POST /v1/auth/challenge {groupId, deviceId}
Srv->>DB: look up device public key
Srv-->>Dev: nonce (single use, 60 s)
Dev->>Dev: sign("jotry/v1/auth" ‖ groupId ‖ deviceId ‖ nonce) with Ed25519
Dev->>Srv: POST /v1/auth/token {deviceId, nonce, signature}
Srv->>Srv: verify signature, nonce unused, device not revoked
Srv-->>Dev: token (15 min), stored hashed
Dev->>Srv: later requests with Authorization: Bearer token
Adding a device is part of enrollment (section 5): the existing device signs a statement “add device X with signing key Y to group G” and the server records it. The server refuses member changes not signed by a current member.
Removing a device. Any member can remove another. The server drops its key and tokens at once, so it can’t fetch anything new. The removed device may still hold RK and note keys it already unwrapped, so a remaining device rotates both (section 7): a new RK, and a new note key for every note (D-64). Records written afterwards are sealed with the new note keys, which the removed device never gets, so it cannot read them even if it obtains the ciphertext. Old records stay readable to someone who copied them before, because they keep their old keys; that is true of any E2EE system.
Entitlement. Hosted sync is paid (D-53), so the server also asks whether a group is entitled, separately from who may talk for it (D-52).
- Creating a group needs a verified store transaction whose account token names the new group id (D-54). The server checks it through a pluggable verifier (development, Apple, Google, Microsoft; D-58), stores it as an entitlement row, and creates the group.
- Several entitlements can sit on one group, from different stores. The group is entitled while any of them is active; the latest expiry wins. Buying again on another store just adds a row.
- Store notifications (renewal, expiry, refund, revoke) update the rows. A transaction maps to one group; moving it to another group is allowed with a cooldown.
- Lapsed groups are read-only: pull and recovery still work, push is refused (D-56). Renewing or restoring the purchase reattaches it. The server deletes the group one year after its last entitlement ended.
- Testing and promo entitlements are rows with their own source. How they are issued is a later decision (D-58).
- Devices in a group are not capped for now (D-52), and no quotas are set yet.
What auth does not do. It doesn’t decrypt anything and isn’t needed to read local notes. Losing every device’s signing key costs server access, not data: the recovery key restores RK, and a new device can re-join through a recovery flow (open question).
HTTPS is not the auth. The server must be safe on the open internet, which is where it sits once sync is offered to other users (D-52).
5. QR enrollment
The new device shows a QR code; an existing device scans it. Scanning a screen in the same room is what proves the new device’s keys belong to it, so the server can’t slip in its own keys. Desktop has no camera, which is why the new device shows the code.
QR payload (D-77): JOTRY1: and base64url of a CBOR map of the server URL, enrollment id, device id, the new device’s X25519 public key and its Ed25519 public key, about 190 characters. The scanning member checks the device id and both keys against the server’s copy.
sequenceDiagram
participant New as New device
participant Srv as Sync server
participant Old as Existing device
New->>New: make X25519 encryption key and Ed25519 signing key (D-74)
New->>Srv: POST /v1/enrollments {enrollmentId, x25519 pub, ed25519 pub}<br/>(no auth, rate limited, expires in 10 min)
New->>New: show QR {server, enrollmentId, x25519 pub, ed25519 pub}
Old->>New: scans QR on screen
Old->>Srv: GET /v1/enrollments/{id} (authenticated)
Old->>Old: check that the keys from the server match the QR
Old->>Old: wrap RK to the X25519 key (110 bytes)<br/>sign "add device" with own Ed25519 key
Old->>Srv: POST /v1/enrollments/{id}/approve {wrappedRootKey, groupId, signed statement}
Srv->>Srv: verify the signature is from a current member,<br/>add the device to the group
New->>Srv: poll or listen for approval
Srv-->>New: {groupId, wrappedRootKey}
New->>New: unwrap RK, store it in Keystore / Keychain,<br/>keep the X25519 key (it receives later rotated keys)
New->>Srv: auth challenge with the new signing key (section 4)
New->>Srv: pull the change feed from the start
If the existing device has no camera (a desktop adding a phone), the roles flip: the existing device shows a QR code with a one-time secret, the new device scans it and sends its public keys with a MAC under that secret, and the existing device checks the MAC before approving. Not designed in detail yet.
Recovery with only the recovery key (D-55)
The 24-word recovery key has 256 bits, so no password stretching is needed. Three values are derived from it with HKDF, each under its own label: a lookup id, an encryption key (X25519) and a recovery signing key (Ed25519). When the group is created, the server stores the lookup id, the two public keys and the root key sealed to the encryption key’s public key (D-75), which any member can replace on a rotation. It never sees the recovery key.
sequenceDiagram
participant New as New device
participant Srv as Sync server
New->>New: user types the 24 words,<br/>derive lookup id, encryption key, recovery signing key
New->>Srv: POST /v1/recovery/challenge {lookupId}
Srv-->>New: nonce (single use, 60 s)
New->>New: sign nonce with the recovery signing key,<br/>make a new Ed25519 device key
New->>Srv: POST /v1/recovery/enroll {lookupId, nonce, signature, new device public key}
Srv->>Srv: verify against the stored recovery public key,<br/>add the device to the group
Srv-->>New: {groupId, recovery_wrapped_root_key}
New->>New: open the root key with the encryption key,<br/>store it in Keystore / Keychain
New->>Srv: normal auth (section 4), then pull the change feed from the start
- It works while the group is lapsed, because it only needs reading (D-56).
- After recovering, the app lists the group’s devices and offers to remove the lost one, which rotates the root key (section 7).
- At setup the user re-enters part of the key before sync turns on, so they have actually saved it.
- Optional keychain backup (D-57). When sync is turned on, the user may also let the OS back up the device key (iCloud Keychain, Android Backup). A restored phone then rejoins without the words. The recovery key is created either way.
6. Encryption at rest
flowchart LR
subgraph Device
UI["App UI"] --> Core["NotesCore"]
Core --> DB[("SQLite<br/>plaintext notes,<br/>OS file protection")]
Core --> Out["Outbox"]
KS["Keystore / Keychain<br/>RK, signing key"]
end
Out -- "seal with NK" --> Net["HTTPS"]
KS -. "unwrap NK in memory" .-> Out
Net --> Pg[("Postgres<br/>ciphertext, wrapped keys,<br/>device public keys")]
On the device. The local database stays plaintext so search, history and export keep working, protected by the OS (D-10, D-11: Android file-based encryption and app sandbox, iOS Data Protection, excluded from cloud backups). RK and the signing key are stored wrapped by a hardware-backed key in Keystore / Keychain, ThisDeviceOnly, never in the database. Wrapped note keys are kept in the database (sync_note_key), and are unwrapped in memory when sealing or opening.
On the server. Postgres holds only what a server without keys may see:
| Stored | Readable by the server? |
|---|---|
| Record bodies (note metadata, revisions) | No, sealed with NK |
| Wrapped note keys | No, sealed with RK |
| Recovery copy of RK | No, sealed with the recovery key |
| Pending enrollment’s wrapped RK | No, sealed to the new device |
| Group id, device ids and public keys | Yes |
| Record ids, note ids, sizes, timestamps, which device wrote what | Yes |
What leaks is the shape of activity: how many notes, how big, when edited, from which device. Ids are random UUIDs, so they carry no titles. Record sizes are not padded (D-50): encrypting the contents is enough for a personal server. Disk encryption on the box and Postgres backups are an extra layer for the case of a stolen disk, not part of the E2EE guarantee.
7. Syncing with the server
Server data model (sketch)
Accepted with changes as D-65: one
recordtable with anotebookkind added, noversioncolumn (the conditional put comparesseq), and tombstones as records with no envelope. The sketch below is the original proposal.
erDiagram
sync_group ||--o{ device : has
sync_group ||--o{ entitlement : has
sync_group ||--o{ record : holds
sync_group ||--|| group_counter : orders
device ||--o{ auth_token : gets
sync_group {
uuid id
bytea recovery_lookup_id
bytea recovery_public_key
bytea recovery_wrapped_root_key
int current_root_key_id
timestamptz lapsed_since
}
entitlement {
uuid id
uuid group_id
text source
text store_transaction_id
text status
timestamptz expires_at
timestamptz bound_at
}
device {
uuid id
uuid group_id
bytea ed25519_public_key
timestamptz added_at
uuid added_by
timestamptz revoked_at
}
record {
uuid group_id
text record_id
text kind
uuid note_id
bigint version
bigint seq
bytea envelope
bool tombstone
uuid author_device_id
}
group_counter {
uuid group_id
bigint last_seq
}
entitlement.source is apple, google, microsoft, promo or dev; lapsed_since is set when the last active entitlement ends and drives the one-year deletion. record.kind is note-key, note or revision; a note-key record holds the whole list of that note’s wrapped keys. version is per record, for conditional puts; seq is per group, for the change feed, and is taken from group_counter under a row lock in the same transaction, so a pulling device never skips a change committed out of order.
Push and pull
sequenceDiagram
participant Core as NotesCore
participant Eng as Sync engine
participant Srv as Sync server
Core->>Eng: local commit (revision, move, delete)
Eng->>Eng: add to outbox
Eng->>Eng: seal records with the note's NK<br/>(new note: make NK, wrap under RK)
Eng->>Srv: PUT /v1/records/{id} {envelope, expectedVersion}
alt version matches
Srv-->>Eng: 200 {version, seq}
Eng->>Eng: drop from outbox
else someone else wrote first
Srv-->>Eng: 409 {current version}
Eng->>Srv: pull, merge, push again
end
Note over Eng,Srv: pull
Eng->>Srv: GET /v1/changes?cursor=seq
Srv-->>Eng: records with seq > cursor, next cursor
Eng->>Eng: unwrap NK, open envelopes,<br/>apply to NotesCore, save cursor
Srv--)Eng: SSE "changed" nudge (optional), triggers a pull
Conflicts. Revisions are immutable and already form a graph (parent_revision_id), so two devices editing the same note offline both upload their revisions without conflict. The note’s head record is what can conflict: on a 409 the engine pulls, sees two heads, and either merges them (text that doesn’t overlap) or keeps both and shows the note as conflicted, with both versions in history. Deletes are tombstones. When a delete on one device meets an edit on another, sync asks the user which to keep (D-49): keep the edited note, or delete it. Until they answer, the note stays, marked as needing a decision, and nothing is lost.
Ordering on the device. The client never trusts server timestamps for order; it uses the revision graph and a hybrid logical clock written into the sealed record.
Root key rotation
flowchart LR
R["Remove device"] --> G["Make RK2 (id + 1)"]
G --> N["Make a new NK for every note<br/>(id + 1), used for later records"]
N --> W["Rewrap every NK, old and new, under RK2<br/>(PUT note-key records)"]
W --> E["Send RK2 to the other devices<br/>(wrapped to each device)"]
E --> D["Keep RK1 too, for key lists<br/>not rewrapped yet (D-75)"]
The steps as built (D-75): the first remaining device to sync after a removal makes RK2, keeps it in its vault and notes the rotation in its database, then asks the server to move the current id from 1 to 2, which also replaces the recovery copy (only one device can win; the server answers 409 root-key-conflict to the other). It rewrites the note-key records and seals RK2 to every other device’s X25519 key, which each collects when it syncs. A device that was offline, or enrolled in the middle of a rotation, shows up in the server’s list of devices that were not given the current key, and any member that holds it sends it. A rotation cut short at any point is settled by the next sync.
Existing records keep the note key that sealed them (the envelope names it), so they are never re-encrypted and only the note-key records are uploaded. From then on each new revision and the note’s head record are sealed with the newest note key. A note’s note-key record is a list: the old keys stay in it so old revisions open, each wrapped under RK2. Handing RK2 to the remaining devices needs a per-device encryption key (the signing key can be converted, or each device keeps a long-term X25519 key); open question below.
8. Threats
| Threat | What stops it |
|---|---|
| Someone reads the server’s disk or backups | Everything there is sealed; keys never reach the server |
| The server swaps or replays ciphertexts | Associated data binds every envelope to its record, note and group |
| The server adds its own device to a group | New members need a statement signed by a current member (covering the signing and encryption keys, D-74), and the key comes from a QR code scanned in person. Not yet checked by clients: a malicious server could show a rotating or enrolling device a member that does not exist (section 9) |
| A stolen phone | OS lock and Keystore / Keychain protect local data; another device removes it and rotates RK and every note key (D-64) |
| Someone on the network | HTTPS; and the content is sealed anyway |
| Another group claims a leaked store receipt | The store’s signed transaction carries the group it was bought for (D-54); the server refuses a mismatch and allows rebinding only with a cooldown |
| One paid group shared among many people | Not prevented: there is no device cap for now (D-52). Per-group quotas are a later decision |
| A test shortcut left on in production | The development verifier cannot be enabled in a production build (D-58); promo entitlements are explicit rows with their own source |
| Losing every device | The 24-word recovery key restores RK from the server copy and lets a new device re-join (section 5, D-55); without it the synced notes are gone, and the app says so (accepted in D-48) |
| A lapsed subscription | The group turns read-only, not locked; it is deleted a year after the last entitlement ended (D-56) |
9. Open questions
Recovery flow: how a device with only the recovery key re-joins.Designed in section 5 (D-55).- Store verification details: the Google Play equivalent of Apple’s account token, how the Microsoft Store verifies purchases server-side (a short spike before committing to Windows), and handling Apple’s Sandbox and Production environments.
- Promo and test entitlements: how they are issued and managed in production (D-58).
- Quotas and rate limits per group, and whether a device cap is ever needed (none for now, D-52).
Distributing a rotated RK to the remaining devices.A long-term X25519 key per device (D-74), and a sealed copy per device on the server (D-75).- Trusting the server’s list of devices. A rotating device seals the new root key to the keys the server lists, and an enrolling device takes the approver’s wrap from the server, which is sealed with an ephemeral key and not signed. A malicious server could add a device of its own to what a rotating device sees and be sent the new root key. Closing this needs clients to check the chain of approvals (each stored with its signature, verified from a device they trust) and the approver to sign the wrap it sends (D-74).
- Camera-less existing device adding a phone: the reverse QR flow in section 5.
Conflict rule for a delete on one device and an edit on another.Ask the user during sync (D-49): on screen while the app is open, through a notification that opens the prompt when sync ran in the background (D-73).Size padding of records.Not done (D-50).Desktop key storage.OS key store, passphrase as fallback (D-51).Recovery key encoding.BIP-39 English, 256 bits plus checksum, HKDF directly from the bits (D-66).Token format.Opaque random tokens, stored hashed in Postgres (D-67): they work across any number of server containers and revoke at once.
UI/UX Design Brief
A prompt for an AI design tool (or a designer) to produce and refine Jotry’s screen designs and visual language. It covers the Release 1 scope only, as defined in the project plan.
The visual direction is chosen: Studio Stationery — Graphite, from the Jotry project in Google Stitch. Both apps already implement it (Android ui/Theme.kt, iOS EditorStyle.swift and the SwiftUI screens), so this brief asks for refinements and missing states, not new directions.
Items marked (new) are decided in the project plan (D-15 to D-25). Both apps now have a first version of each (with a thumb-reach bottom bar: Notebooks, Search and New note); generated titles are on iOS only so far. They still need considered designs: the top-level list and hidden default notebook, pinned notes, multi-select, the undo bar, quick capture, automatic titles, note lookup with back/forward, and links between notes. Everything else describes what the apps do today; designs must fit it rather than invent features.
Paste everything below the line into the design tool.
You are a senior product designer. Refine the screens and visual language of Jotry, a private note-taking app for Android and iPhone. The design direction is established (below); keep it, extend it to every screen and state listed, and point out anything that weakens it.
The product
Jotry is a calm, trustworthy place to write. People write formatted notes, keep them in notebooks, and can go back to earlier versions of a note. It works fully offline with no account and no cloud. Nothing leaves the device. The emotional promise is: “your words are safe here, and nothing is ever lost.”
Core principles that must show up in the design:
- Local and private: no sign-in, no sync badges, no social features. A quiet “Saved” badge is the only save indicator.
- History is kept: changes are saved automatically and kept as versions (one per minute of writing, see the editor). Restoring an old version never erases the current one.
- Formatting without syntax: notes are stored as Markdown behind the scenes, but users never see Markdown characters. Headings, bold, lists, quotes, code, links and checkboxes always appear formatted, including while typing. There is no “source mode”.
- No decisions before writing (new): capturing a thought needs no title and no notebook choice. Titles are automatic.
- Undo, not “Are you sure?” (new): deleting and moving happen at once and offer a brief Undo bar instead of a confirmation dialog.
Design direction: Studio Stationery — Graphite
Charcoal ink on archival paper. Restrained, precise and quiet, drawn from drafting paper, ledger books and index cards, translated into modern flat form (no skeuomorphic textures).
- Color: charcoal ink and slate graphite for content and controls, muted slate for metadata, green reserved for the “Saved” state only. No other accent color. Depth comes from tone and 1px hairline borders, not shadows.
- Type: DM Sans for interface and note text; JetBrains Mono for metadata stamps (dates, times, counts, badges), usually uppercase and letterspaced, and for code. Both are free (SIL Open Font License) and bundled in the apps.
- Metaphors in use: notebooks as bound volumes with a coloured spine on the card’s left edge; notes as index cards; pinned notes as cards clipped to the top of the desk; history as a dated timeline of drafts; the formatting toolbar as a pen tray docked on the keyboard; checkboxes as square ledger ticks.
- Shapes: 4dp/pt controls, 8 cards, 12 sheets and dialogs. Icons are line icons at about 1.75px stroke with rounded caps.
Tokens in use (light / dark)
| Role | Light | Dark |
|---|---|---|
| Paper (canvas) | #F8FAFC | #0B0F19 |
| Sheet (cards, writing surface) | #FFFFFF | #131B2E |
| Subtle (fills, chips, inputs) | #F1F5F9 | #1A233A |
| Rule (hairlines) | #E2E8F0 | #222F43 |
| Ink (text, primary buttons, active tools) | #0F172A | #F8FAFC |
| On ink | #FFFFFF | #0F172A |
| Slate (secondary headings, icons) | #334155 | #CBD5E1 |
| Muted (metadata, secondary text) | #64748B | #94A3B8 |
| Faint (decorative only, never body text) | #94A3B8 | #64748B |
| Saved / saved background | #059669 / #ECFDF5 | #10B981 / #0B2A22 |
| Destructive / background / border | #991B1B / #FEF2F2 / #FEE2E2 | #FCA5A5 / #2A1215 / #4C1D1D |
| Code background | #EEF1F4 | #1A233A |
| Quote background / bar | #F8FAFC / #334155 | #172036 / #94A3B8 |
| Search highlight | #E2E8F0 | #334155 |
Type scale in use: note title 26/34 bold; headings 22/30, 19/26 and 17 semibold; note body 16/26; card title 16/22 semibold; secondary 13/20; mono stamps 12/16 and 11/16; code 14/21 mono. Propose changes only with a reason.
Platform stance
- One shared visual identity across Android and iOS: the same colors, type, iconography, components and motion.
- Keep behaviors people rely on, and use the system’s own versions of them: back gesture, text selection handles, the system keyboard, share and file pickers, Dynamic Type and font scaling, screen readers. Don’t redraw these. Style around them.
- Avoid platform signatures that would make it look like a clone: no Material FAB-and-app-bar template and no default iOS grouped-list look.
- Phones first (portrait). Note how layouts could grow to tablets later.
Screens to design (Release 1)
The hierarchy has exactly two levels (D-15): the top level holds notebooks plus the notes that aren’t in any notebook, and notebooks hold only notes (no nesting). Notes created outside a notebook go into a built-in default notebook that the user never sees as a notebook: its notes simply appear at the top level. There is no visible Inbox.
- Top-level list (new; replaces the notebook list and its Inbox card): the app mark at the top; then, in order, pinned top-level notes, the notebooks, and the unpinned top-level notes (most recently edited first). Notebook cards keep the spine, “VOL. 01 · 12 NOTES” stamp and name, with rename, reorder (move up/down), archive and delete in a card menu. Deleting a notebook never deletes its notes: they move to the top level, and the Undo bar puts the notebook and its notes back (D-21: once the bar has gone, the notebook can’t be restored from Trash, but no notes are lost). Archived notebooks sit in a collapsible “Archived” section. A clear “New note” action and a quieter “New notebook” action. Search, Trash and Settings are reached from the top bar.
- Notebook’s note list: a header card with the notebook name, note count and a full-width “New note” primary button; then pinned notes, then “Sorted by last edit” and note cards with a time stamp (“TODAY 14:32”, “YESTERDAY 18:05”, “MAR 12”), title, two-line preview and a task-progress badge (“1/3 tasks”). Card menu: Pin/Unpin, Move, Move to trash.
- Pinned notes (new, D-16 and D-19): pin and unpin from a note’s menu (and from the note itself). Pinned top-level notes show at the very top of the top-level list; pinned notes in a notebook show at the top of that notebook only. Pinned notes are ordered by most recent edit, with no limit and no manual ordering. Pinning isn’t an edit (no new version, no change to the edited time), so the design should not imply it changed the note. Give pinned cards a quiet pin marker and a section label.
- Multi-select (new, D-18): in any note list, press and hold a note to enter selection mode with it selected; tap to add or remove notes; Select all; then Move (to any notebook or the top level) or Delete. The bar shows how many are selected. Back or Cancel leaves selection mode and changes nothing.
- Note editor, the most important screen:
- A live formatted editor with headings (three levels; the tool cycles H1 → H2 → H3 → off), bold, italic, strikethrough, bullet and numbered lists, nested checklists shown as real tappable checkboxes, quotes, inline code, code blocks and links.
- Lists continue on Return; Return on an empty item steps out one level, then out of the list. Indent and outdent move list items.
- Pen-tray toolbar docked above the keyboard: a stats row (word and character counts, undo, redo), then a horizontally scrolling rail with heading, bold, italic, strikethrough, checklist, bulleted list, numbered list, indent, outdent, quote, inline code, code block and link. Active tools are filled ink with on-ink glyphs. On iOS the rail also has “hide keyboard”.
- Automatic title (new, D-23): the user never has to type a title, and no separate title field is shown while writing. The first line serves as the title at once; on devices with an on-device language model (Apple Foundation Models, Gemini Nano), a short generated title may replace it once the note has been idle for a few seconds. Nothing leaves the device and nothing is ever downloaded or switched on for this. Show the title in the note header; tapping it edits it, and a hand-edited title is never replaced. Design how a generated title arrives (quietly, without moving the text being typed) and how a user-set title looks, if at all different. (Today’s apps still have an optional title field; it goes away with this.)
- Links (changed, D-25): the link tool opens a single field, with no choice to make first. If what’s typed looks like a web address (a scheme such as https:// or mailto:, a leading www., or a domain like example.com/page, with no spaces), the top suggestion is that web link. Otherwise the field lists matching notes from the note lookup, and offers “Create note “…”” when nothing matches. Selected text becomes the link’s label. Typing
[[in the text opens the same note lookup inline and always means a note link. Note links and web links both look like links (never syntax); consider whether note links are distinguishable (for example a small note glyph). A note link shows the target’s current title unless the user typed a label, and keeps working when the target is renamed or moved. Editing an existing link offers Remove. - Code is typed exactly: inside code the keyboard stops capitalizing, autocorrecting and substituting smart quotes. Show code visibly distinct (monospace on a code panel).
- Save state: a pill badge in the editor header: “SAVED” (green dot), “EDITED”, “SAVING” or “NOT SAVED”. No save button. The app saves once 35 characters have changed or 10 seconds after the first unsaved change, and immediately when the editor closes, the note is left or the app goes to the background.
- The note’s notebook appears as a badge in the header. Entry points: Done (back to the read view), note lookup, History, Pin, Move to notebook and Move to trash.
- A new note opens straight in the editor. A new note left without any content is discarded when you leave it; design no prompt for this.
- Note lookup and back/forward (new, D-17 and D-20): from inside any note (reading or editing), open a lookup that fuzzy-searches note titles, then bodies; title matches rank first, and trashed notes never appear. Choosing a result opens that note. Navigation then works like a browser: Back returns to the previous note, Forward returns again, repeated jumps extend the trail, and notes deleted during the session are skipped. Following a note link counts as a jump. Design the lookup (a sheet or overlay with a field and ranked results, highlighting matches as search does) and how back and forward are offered inside a note, distinct from leaving the note. The trail isn’t kept between launches; the app reopens on the last open note.
- Read view of a note: the note on a sheet, formatted and read-only, but checkboxes can still be toggled and links can be followed; an Edit action; a notebook badge and “EDITED TODAY 14:32” stamp. A note in the trash shows an “In the trash” banner with Restore.
- History timeline: versions grouped by day (“TODAY — SEP 30”, “YESTERDAY — SEP 29”, “FRI, SEP 26”), each with its time (HH:MM, local), what happened (Edited, Checklist updated, Moved, Moved to trash, Restored from trash, Restored earlier version, Imported, Copied from history, Created) and the title at that time. The newest is marked CURRENT. While writing, history keeps one version per minute; other actions always add a version. Explain in one line that restoring never removes the current version.
- Revision preview: the old version on a sheet with its date, time and reason, and two clear actions, “Restore as current” (primary, disabled when it is already current) and “Copy to new note” (secondary). Restoring is not destructive, so it may keep a reassuring confirmation: it becomes a new current version and the present one stays in history.
- Search: a search field over titles, text and checklist items; result cards with the notebook badge, task progress and a snippet, with matches highlighted in title and snippet (ink, heavier weight, on the highlight token). States: before typing, results with a count, and no matches. Results can be selected like any note list (press and hold, or Select in the More menu) and moved or deleted together (D-41).
- Trash: deleted notes as cards with Restore; they keep their full history. Opening one shows the read view with the “In the trash” banner. Permanently deleting from Trash is still an open question; the leading option is to delay the purge until the Undo bar has gone, so it needs no dialog either.
- Settings: export all notes as Markdown files (one folder per notebook), import Markdown files or a folder (with a plain-language import summary), a short privacy statement (notes stay on this device; no account, cloud or sync; Android backups only when end-to-end encrypted, iOS backups exclude notes), and the app version.
- Quick capture (new, D-22): long-pressing the app icon offers Quick capture (an Android app shortcut, an iOS Home Screen quick action). It opens a blank note with the keyboard already up, at the top level, with nothing to choose. Leaving it untouched discards it. Design the shortcut’s label and glyph and confirm the editor needs no extra chrome for this entry.
- Undo bar (new, D-24): deleting a note, deleting selected notes, deleting a notebook and moving notes happen immediately and show a brief bar at the bottom (“Moved to trash · Undo”, “3 notes moved to Work · Undo”, “Notebook deleted, its notes are now at the top level · Undo”). It stays about six seconds and is dismissed by the next action. Undo puts everything back exactly. There are no “Are you sure?” dialogs for these actions.
- Empty states and first run: an empty top level (no notebooks and no notes yet), an empty notebook, no pinned notes (show nothing), no lookup or search results, empty trash. Each is a line icon on a small paper tile, a short title and one warm sentence. There’s no sign-up or onboarding wall.
Also cover these states: loading, errors (for example an import failure or a failed save, written in plain language), and long titles, very long notes, long code lines, deeply nested lists and a long trail of looked-up notes.
App icon
The mark, named Jotmark (D-32), is a capital J whose top bar keeps only its right half (reading like a square-root sign), with a dot where the left half of the bar would be: paper (#F8FAFC) on ink (#0F172A). The app’s header tile shows the same Jotmark glyph. It must also work as an Android themed (monochrome) icon and as iOS dark and tinted icons.
Deliverables
- Design tokens: confirm or adjust the tokens above for light and dark, plus spacing and motion (durations and easing, including opening a notebook, entering history and the checkbox tick).
- Component library: notebook card with spine, note card (normal, pinned, selected), section header with count badge, stamps and badges, the save badge, the note header with its automatic title, pen-tray tools (normal, active, disabled), ledger checkbox (open, done, nested), quote and code panels, web and note links, buttons (primary, secondary, destructive), search field, the note lookup, the single-field link picker, the selection-mode bar, the Undo bar, back/forward controls inside a note, dialogs (rename, restore), empty-state tiles, and the icon style.
- High-fidelity mockups for every screen and state above in light and dark mode, with notes on any Android and iOS differences in behavior only.
- Key interaction specs: the checkbox tick, toolbar behavior with and without a text selection, the single-field link picker and
[[, the note lookup and back/forward, entering and leaving selection mode, the Undo bar appearing and timing out, a generated title arriving, quick capture from the app icon, and the save badge changing state.
Quality bar
- Accessibility: WCAG AA contrast in both themes (faint is decorative only), touch targets of at least 44pt/48dp, layouts that hold up at the largest text sizes, labels for every control that screen readers can read, and meaning never shown by color alone (the save badge also says it in words; active tools are also announced as selected). Checkboxes must work as real, accessible checkboxes.
- Typography comes first. It’s a writing app, so the note page should be as comfortable to read and write on as a well-set book.
- Restraint: no accent color beyond the saved green, hairlines instead of shadows, and depth from tone and layering.
- Every choice should reinforce “calm, private, nothing is ever lost.”
Out of scope (don’t design these)
Nested notebooks; a visible Inbox or default notebook; manual ordering of pinned notes; restoring a deleted notebook from Trash; widgets or share-sheet capture (future options); prompts to download or enable an on-device model; accounts, sign-in or profile avatars; cloud sync, sync or encryption status badges; sharing or collaboration; tags and filter chips; named versions, hashes or document IDs; word-count diffs in history; attachments or images; diagrams (Mermaid) and maths, which stay plain code blocks in Release 1; reminders or due dates; plugins; and desktop or web layouts.
Tests
What the automated tests check, grouped by feature. Use it to review coverage and to find behavior that no test protects. How to run the suites is in CONTRIBUTING.md.
How to use this folder
- Is a behavior tested? Find its area in the coverage map and open only that file. Each area file lists behaviors, the tests that cover each one per layer, a Not tested line naming the layers without a test, then the area’s Gaps.
- Not listed means not known to be tested. If a behavior isn’t in its area file, treat it as untested until you’ve checked the tests named there.
- Nothing tests it at all? Modules and code with no tests are in untested-areas.md.
- When you add, rename or remove a test, update its entry in the area file and the test files table. When a test closes a gap, remove the gap.
Tests are written as File.testName. Swift tests drop the test prefix here, so NoteEditingUITests.testUndoRemovesTyping is written NoteEditingUITests.UndoRemovesTyping.
Layers
| Layer | Folder | What it exercises |
|---|---|---|
| Shared, common | app/sharedLogic/src/commonTest | Pure rules: Markdown codec, editor session, diff, links, search matching, trail. Runs on JVM, Android host and iOS simulator |
| Shared, JVM | app/sharedLogic/src/jvmTest | NotesCore against an in-memory SQLite (JdbcSqliteDriver): storage, revisions, organizing, export and import; the sync engine between devices through a fake server and through the real server in process (KtorDestination over HTTP) |
| Android UI | app/androidApp/src/androidTest | Compose + Espresso on a device or emulator, empty in-memory database per test |
| Android integration | app/androidApp/src/androidTest (ExportIntegrationTest) | Export and import end to end on Android SQLite and real files, no UI |
| Android screenshots | app/androidApp/src/test | Roborazzi + Robolectric on the JVM, compared with src/test/screenshots |
| iOS UI | app/iosApp/JotryUITests | XCUITest, app launched with -ui-testing (in-memory database) |
| iOS integration | app/iosApp/JotryIntegrationTests | Export and import end to end on the iOS core and real files, no UI |
| iOS snapshots | app/iosApp/JotrySnapshotTests | swift-snapshot-testing, references recorded on iPhone 17 Pro, iOS 26.5 |
| Crypto, common | crypto/src/commonTest | Sync encryption: note keys, record envelopes, QR enrollment wrap, recovery key, signing keys, key vault. Runs on JVM, iOS simulator and an Android device or emulator |
| Crypto, Android | crypto/src/androidDeviceTest | The Android Keystore secret store, on a device or emulator |
| Crypto, iOS | crypto/src/iosTest | The Keychain secret store, on a booted iOS simulator |
| Server, service | server/service/src/test | The server’s framework-free logic, and the store contracts on the in-memory stores. Runs on the JVM alone |
| Server, development build | server/dev/src/test | The development verifier (D-70), which only a development build contains. Runs on the JVM alone |
| Server, Postgres adapter | server/postgres/src/test | Migrations, the database probe and settings, and the store contracts, on a real Postgres (Testcontainers or JOTRY_TEST_DATABASE_URL), a fresh database per test |
| Sync API | syncApi/src/commonTest | The CBOR request and response classes (D-63) and the signed statements (D-68): bytes on the wire, round trips, unknown fields. Run on the JVM, iOS simulator and an Android device or emulator |
| Server, Ktor adapter | server/src/test | HTTP routes and the Koin wiring through Ktor’s test host, and the promo tool’s commands; the wiring test uses a real Postgres |
| Desktop | app/desktopUi/src/test | Compose desktop UI tests (runComposeUiTest, offscreen) and NotesCore on a SQLite file, on the JVM |
Coverage map
| Area | File | Shared | Android UI | iOS UI | Main gaps |
|---|---|---|---|---|---|
| Notebooks, pins, moving notes | notebooks-and-organizing.md | ✅ | ✅ | ✅ | Android left-edge swipe for the drawer |
| Editor, formatting, checklists, saving, titles | editor.md | ✅ | ✅ | ✅ | Headings and lists in the UI, 10-second autosave, generated titles on devices |
| Markdown parsing and writing | markdown.md | ✅ | n/a | n/a | Mermaid fences, images, deep nesting |
| Note links, web links, lookup, back and forward | links-and-navigation.md | ✅ | ✅ | ✅ | Links to trashed notes, tapping web addresses in the read view |
| History, versions and changes | history.md | ✅ | ✅ | ✅ | Spoken “added/removed”, strike-through |
| Search | search.md | ✅ | ✅ | ✅ | Selecting all results |
| Trash, delete and undo | trash.md | ✅ | ✅ | ✅ | App closing before Empty trash’s undo bar expires |
| Markdown export and import | export-import.md | ✅ | ✅ | ✅ | Importing single files from Settings, unreadable files |
| Storage, migration, hashes, ordering | storage.md | ✅ | — | — | Only the version 1 migration |
| Sync encryption (crypto module: keys, envelopes, enrollment, recovery, key storage) | sync-crypto.md | ✅ | n/a | n/a | Windows and Linux desktop key stores; the D-57 keychain backup |
| Sync engine and the apps’ sync screens (turning sync on, joining by QR code, outbox, push, pull, merging, delete-versus-edit, root key rotation) | sync-engine.md | ✅ | ✅ | ✅ | The engine not on Android or iOS; the apps’ status screen and turning sync off; records waiting for their key; how the engine handles refused writes and a read-only group |
| Sync server and API (health, migrations, groups, entitlements, promo codes, records, device auth, enrollment, recovery, root key rotation, rate limits, maintenance, CBOR) | server.md | n/a | n/a | n/a | Reverse QR and the store verifiers are not built; the sync engine calls the auth and record routes only in the shared JVM tests |
| Desktop window, editor and database | desktop.md | n/a | n/a | n/a | The native binary and the Nucleus window; most formatting; window screens beyond the main flows |
| Screenshots (visual) | screenshots.md | n/a | ✅ | ✅ | Android has no empty-search screenshot |
| No tests at all | untested-areas.md | Localization, platform text styling, sync contracts and web |
Test files
| Test file | Layer | Tests | Areas |
|---|---|---|---|
commonTest/.../domain/DomainSupportTest.kt | Shared, common | 7 | storage, export-import |
commonTest/.../editor/EditorSessionTest.kt | Shared, common | 20 | editor, links-and-navigation |
commonTest/.../history/RevisionDiffTest.kt | Shared, common | 12 | history |
commonTest/.../links/NoteLinksTest.kt | Shared, common | 7 | links-and-navigation |
commonTest/.../markdown/MarkdownCodecTest.kt | Shared, common | 12 | markdown |
commonTest/.../navigation/NoteTrailTest.kt | Shared, common | 2 | links-and-navigation |
commonTest/.../search/SearchIndexTest.kt | Shared, common | 6 | search |
jvmTest/.../NotesCoreTest.kt | Shared, JVM | 25 | storage, history, search, trash, export-import, notebooks-and-organizing, links-and-navigation, editor |
jvmTest/.../OrganizingTest.kt | Shared, JVM | 10 | notebooks-and-organizing, editor, links-and-navigation, storage |
jvmTest/.../sync/SyncEngineTest.kt | Shared, JVM | 14 | sync-engine |
jvmTest/.../sync/SyncFaultTest.kt | Shared, JVM | 4 | sync-engine |
jvmTest/.../sync/SyncOnServerTest.kt | Shared, JVM | 18 | sync-engine: SyncEngineTest and SyncFaultTest again, against the real server in process |
jvmTest/.../sync/KtorDestinationTest.kt | Shared, JVM | 9 | sync-engine |
jvmTest/.../sync/CloudSyncTest.kt | Shared, JVM | 9 | sync-engine |
jvmTest/.../sync/EnrollmentTest.kt | Shared, JVM | 5 | sync-engine |
jvmTest/.../sync/RootKeyRotationTest.kt | Shared, JVM | 10 | sync-engine |
jvmTest/.../sync/LiveSyncServerTest.kt | Shared, JVM | 1 | sync-engine; skipped unless JOTRY_SYNC_SERVER and JOTRY_PROMO_CODE are set |
jvmTest/.../sync/FakeSyncServer.kt, SyncTestServer.kt, InProcessSyncServer.kt, HttpSyncGroup.kt, SyncTestDevices.kt | Shared, JVM | 0 | Fixtures: the in-memory server with fault injection, the real server started in process, setting up a group over HTTP (create, QR enrollment), and test devices |
androidTest/.../NotebookUiTest.kt | Android UI | 12 | notebooks-and-organizing |
androidTest/.../NoteEditingUiTest.kt | Android UI | 14 | editor, links-and-navigation |
androidTest/.../HistorySearchTrashUiTest.kt | Android UI | 11 | history, search, trash, export-import (settings text) |
androidTest/.../SyncUiTest.kt | Android UI | 3 | sync-engine |
androidTest/.../ExportUiTest.kt | Android UI | 1 | export-import |
androidTest/.../ImportUiTest.kt | Android UI | 1 | export-import |
androidTest/.../ExportIntegrationTest.kt | Android integration | 1 | export-import |
androidTest/.../JotryUiTest.kt | Android UI | 0 | Base class: in-memory core, helpers such as typeInBody |
androidApp/src/test/.../ScreenshotTest.kt | Android screenshots | 12 | screenshots |
JotryUITests/NotebookUITests.swift | iOS UI | 14 | notebooks-and-organizing |
JotryUITests/NoteEditingUITests.swift | iOS UI | 14 | editor, links-and-navigation |
JotryUITests/HistorySearchTrashUITests.swift | iOS UI | 11 | history, search, trash, export-import (settings text) |
JotryUITests/SyncUITests.swift | iOS UI | 3 | sync-engine |
JotryUITests/ExportUITests.swift | iOS UI | 1 | export-import |
JotryUITests/ImportUITests.swift | iOS UI | 1 | export-import |
JotryUITests/JotryUITestCase.swift | iOS UI | 0 | Base class: launch with -ui-testing, helpers |
JotryIntegrationTests/ExportIntegrationTests.swift | iOS integration | 2 | export-import |
JotrySnapshotTests/ScreenSnapshotTests.swift | iOS snapshots | 13 | screenshots |
crypto/.../NoteKeysTest.kt | Crypto, common | 9 | sync-crypto |
crypto/.../DeviceNamesTest.kt | Crypto, common | 6 | sync-crypto |
crypto/.../RecordCipherTest.kt | Crypto, common | 9 | sync-crypto |
crypto/.../DeviceEnrollmentTest.kt | Crypto, common | 7 | sync-crypto |
crypto/.../RecoveryKeyTest.kt | Crypto, common | 12 | sync-crypto |
crypto/.../SigningKeyTest.kt | Crypto, common | 5 | sync-crypto |
crypto/.../KeyVaultTest.kt | Crypto, common | 5 | sync-crypto |
crypto/.../DeviceEncryptionKeyTest.kt | Crypto, common | 5 | sync-crypto |
crypto/src/androidDeviceTest/.../AndroidSecretStoreTest.kt | Crypto, Android | 4 | sync-crypto |
crypto/src/iosTest/.../IosSecretStoreTest.kt | Crypto, iOS | 3 | sync-crypto |
desktopUi/src/test/.../JotryDesktopUiTest.kt | Desktop | 12 | desktop |
desktopUi/src/test/.../DesktopEditorUiTest.kt | Desktop | 5 | desktop |
desktopUi/src/test/.../DatabaseTest.kt | Desktop | 3 | desktop |
desktopUi/src/test/.../LibraryEditorUiTest.kt | Desktop (ComposeTextEditor spike) | 5 | desktop |
desktopUi/src/test/.../DesktopSyncUiTest.kt | Desktop | 5 | desktop |
desktopUi/src/test/.../MacKeychainSecretStoreTest.kt | Desktop (macOS only) | 4 | desktop |
sharedUI/src/commonTest/.../SharedUICommonTest.kt | Template | 1 | Placeholder (example), tests nothing |
server/service/src/test/.../HealthServiceTest.kt | Server, service | 4 | server |
server/service/src/test/.../EntitlementPolicyTest.kt | Server, service | 8 | server |
server/service/src/test/.../InMemoryGroupStoreTest.kt | Server, service | 16 (GroupStoreContract) | server |
server/service/src/test/.../RecordServiceTest.kt | Server, service | 13 | server |
server/service/src/test/.../AuthServiceTest.kt | Server, service | 13 | server |
server/service/src/test/.../EnrollmentServiceTest.kt | Server, service | 6 | server |
server/service/src/test/.../RecoveryServiceTest.kt | Server, service | 6 | server |
server/service/src/test/.../GroupCreationTest.kt | Server, service | 9 | server |
server/service/src/test/.../PurchasesTest.kt | Server, service | 8 | server |
server/service/src/test/.../InMemoryAuthStoreTest.kt | Server, service | 16 (AuthStoreContract) | server |
server/service/src/test/.../RootKeyServiceTest.kt | Server, service | 13 | server |
server/service/src/test/.../RateLimitTest.kt | Server, service | 9 | server |
server/service/src/test/.../MaintenanceTest.kt | Server, service | 5 | server |
server/service/src/test/.../InMemoryRecordStoreTest.kt | Server, service | 8 (RecordStoreContract) | server |
server/service/src/test/.../InMemoryPromoStoreTest.kt | Server, service | 10 (PromoStoreContract) | server |
server/service/src/test/.../PromoTest.kt | Server, service | 8 (PromoCodesTest 3, PromoVerifierTest 5) | server |
server/postgres/src/test/.../PostgresDatabaseTest.kt | Server, Postgres adapter | 5 | server |
server/postgres/src/test/.../PostgresSettingsTest.kt | Server, Postgres adapter | 3 | server |
server/postgres/src/test/.../PostgresGroupStoreTest.kt | Server, Postgres adapter | 16 (GroupStoreContract) | server |
server/dev/src/test/.../DevelopmentVerifierTest.kt | Server, development build | 3 | server |
server/postgres/src/test/.../PostgresRecordStoreTest.kt | Server, Postgres adapter | 8 (RecordStoreContract) | server |
server/postgres/src/test/.../PostgresAuthStoreTest.kt | Server, Postgres adapter | 16 (AuthStoreContract) | server |
server/postgres/src/test/.../PostgresPromoStoreTest.kt | Server, Postgres adapter | 10 (PromoStoreContract) | server |
syncApi/src/commonTest/.../SyncApiTest.kt | Sync API | 7 | server |
syncApi/src/commonTest/.../SyncStatementsTest.kt | Sync API | 7 | server |
server/src/test/.../ApplicationTest.kt | Server, Ktor adapter | 3 | server (testRoot is the template placeholder) |
server/src/test/.../WiringTest.kt | Server, Ktor adapter | 3 | server |
server/src/test/.../SyncRoutesTest.kt | Server, Ktor adapter | 12 | server |
server/src/test/.../PromoToolTest.kt | Server, Ktor adapter | 5 | server |
server/src/test/.../AuthRoutesTest.kt | Server, Ktor adapter | 14 | server |
server/src/test/.../RootKeyRoutesTest.kt | Server, Ktor adapter | 6 | server |
server/src/test/.../RateLimitRoutesTest.kt | Server, Ktor adapter | 12 | server |
Totals: 169 shared tests (66 common, 103 JVM), 43 Android device tests (42 UI, 1 integration), 12 Android screenshots, 46 iOS tests (44 UI, 2 integration), 13 iOS snapshots, 34 desktop tests (27 UI, 3 database, 4 Keychain), 263 server tests (149 service, 57 Postgres adapter, 54 Ktor adapter, 3 development build), 14 sync API tests.
Desktop
The desktop app in app/desktopUi (D-76; the desktop plan has the spike behind it): the window with its sidebar, top bar, notes grid, editor and history, plus the editor and database. The tests run on the JVM with ./gradlew :app:desktopUi:test, in-process with no window. The window tests use a 1280×800 offscreen scene (runSkikoComposeUiTest) and a temporary database file.
- A window with no notes offers a new note
- Desktop UI:
JotryDesktopUiTest.anEmptyWindowOffersANewNote
- Desktop UI:
- A new note takes typing and saves it (the Saved badge shows once the text is written)
- Desktop UI:
JotryDesktopUiTest.aNewNoteTakesTypingAndSavesIt
- Desktop UI:
- Text typed in the last half second is saved when the note is closed
- Desktop UI:
JotryDesktopUiTest.closingANoteSavesTextTypedInTheLastHalfSecond
- Desktop UI:
- Pinned notes get their own section at the top of the grid
- Desktop UI:
JotryDesktopUiTest.pinnedNotesGetTheirOwnSectionAtTheTop
- Desktop UI:
- Deleting shows the Undo bar, and Undo brings the note back
- Desktop UI:
JotryDesktopUiTest.deletingShowsUndoAndUndoBringsTheNoteBack
- Desktop UI:
- The trash lists deleted notes and restores them
- Desktop UI:
JotryDesktopUiTest.theTrashListsDeletedNotesAndRestoresThem
- Desktop UI:
- Search from the top bar lists the notes that match in a sheet over the page, and only those
- Desktop UI:
JotryDesktopUiTest.searchFindsNotesByTheirText
- Desktop UI:
- Clicking a search result opens the note and closes the sheet
- Desktop UI:
JotryDesktopUiTest.clickingASearchResultOpensTheNoteAndClosesTheResults
- Desktop UI:
- Ctrl/Cmd+click on a note link in the editor opens the target note (a plain click only places the caret; web links go to the system’s opener, which is not tested)
- Desktop UI:
JotryDesktopUiTest.commandClickOnANoteLinkOpensTheTargetNote
- Desktop UI:
- Typing
[[in the editor opens the note picker, and choosing a note replaces the brackets with a link to it- Desktop UI:
JotryDesktopUiTest.typingDoubleBracketsOffersNotesAndChoosingOneInsertsALink
- Desktop UI:
- A new notebook gets a place in the sidebar and takes new notes
- Desktop UI:
JotryDesktopUiTest.aNewNotebookGetsAPlaceInTheSidebarAndTakesNewNotes
- Desktop UI:
- History lists the versions, shows what changed, and restores an earlier one
- Desktop UI:
JotryDesktopUiTest.historyListsVersionsAndRestoresAnEarlierOne
- Desktop UI:
- Typing goes through the shared editor session
- Desktop UI:
DesktopEditorUiTest.typingGoesThroughTheSession
- Desktop UI:
- The Bold button formats the selection (the toolbar can’t take focus, so the editor keeps its selection)
- Desktop UI:
DesktopEditorUiTest.boldButtonFormatsTheSelection
- Desktop UI:
- Cmd+B makes the selection bold and Cmd+Z undoes it through the session, not the text field’s own undo
- Desktop UI:
DesktopEditorUiTest.commandBIsBoldAndCommandZUndoesThroughTheSession
- Desktop UI:
- A checklist line gets a checkbox in the gutter, and clicking it checks the task
- Desktop UI:
DesktopEditorUiTest.checklistLineGetsACheckboxThatToggles
- Desktop UI:
- Enter on a checklist line starts a new task
- Desktop UI:
DesktopEditorUiTest.enterOnAChecklistLineStartsANewTask
- Desktop UI:
- ComposeTextEditor spike: typing goes through the shared editor session
- Desktop UI:
LibraryEditorUiTest.typingIsForwardedToTheSession
- Desktop UI:
- ComposeTextEditor spike: Undo and Redo come from the session and replace the library’s text
- Desktop UI:
LibraryEditorUiTest.undoComesFromTheSessionNotTheLibrary
- Desktop UI:
- ComposeTextEditor spike: Bold goes through the session and the text is restyled
- Desktop UI:
LibraryEditorUiTest.boldGoesThroughTheSessionAndRestylesTheText
- Desktop UI:
- ComposeTextEditor spike: Enter on a checklist line is continued by the session only (the library’s own list behavior is switched off)
- Desktop UI:
LibraryEditorUiTest.enterOnAChecklistLineIsContinuedByTheSessionOnly
- Desktop UI:
- ComposeTextEditor spike: Markdown round-trips through the library’s text
- Desktop UI:
LibraryEditorUiTest.markdownRoundTripsThroughTheLibraryText
- Desktop UI:
- The core works on a database file: edit, save, reopen, search, history and export (also run inside the native binary with
JOTRY_SMOKE=1, which also loads libsodium and makes keys; withJOTRY_SMOKE_SYNC_SERVER=https://…it also registers a join request on that server, which tries the HTTP client, TLS and CBOR in the build)- Desktop JVM:
DatabaseTest.smokeTestPassesOnAFreshFile
- Desktop JVM:
- Notes survive reopening the database file
- Desktop JVM:
DatabaseTest.notesSurviveReopening
- Desktop JVM:
- A version 1 database file is migrated on open
- Desktop JVM:
DatabaseTest.aVersionOneDatabaseIsMigrated
- Desktop JVM:
- The macOS Keychain store keeps secrets byte for byte, replaces them on the next put, keeps them per service, and returns null for an unknown name (the real login Keychain under a throwaway service; skipped off macOS)
- Desktop JVM:
MacKeychainSecretStoreTest.aSecretComesBackByteForByteAndIsReplacedByTheNextPut,anUnknownNameIsNullAndDeletingTwiceIsFine,secretsAreKeptPerService
- Desktop JVM:
- A key name that could break the
securitycommand is refused- Desktop JVM:
MacKeychainSecretStoreTest.aNameThatCouldBreakTheCommandIsRefused
- Desktop JVM:
- Where no key store is built, the Sync screen says so
- Desktop UI:
DesktopSyncUiTest.whereNoKeyStoreIsBuiltSyncSaysSo
- Desktop UI:
- Turning on sync needs an address and a code, then shows the 24 words and checks the words typed back
- Desktop UI:
DesktopSyncUiTest.turningOnNeedsAnAddressAndACodeThenShowsTheWordsAndChecksThem
- Desktop UI:
- Joining by QR code needs the server address first, and an unreachable server is reported with a button for a new code
- Desktop UI:
DesktopSyncUiTest.joiningNeedsTheServerAddressFirst,joiningAnUnreachableServerSaysSoAndOffersANewCode
- Desktop UI:
- The build’s server address is suggested on the Sync screen and can be replaced
- Desktop UI:
DesktopSyncUiTest.theBuildsServerAddressIsSuggestedAndCanBeChanged
- Desktop UI:
Gaps
-
Sync on the desktop is tested only up to the server: no test creates a group, joins by code, approves a pasted code or syncs from the window (the engine itself is tested in sync-engine.md), and neither is the status view, turning sync off, nor syncing when the window comes to the front.
-
The Keychain store and the copy buttons have not been tried inside the native binary (only libsodium and the HTTPS request have, by the smoke run).
-
The Keychain store is not run in CI (the runners’ Keychains differ), and nothing tests that another program of the user can read the items without a prompt, which the store’s description says it can.
-
The native binary is checked only by the smoke run (
JOTRY_SMOKE=1) and the first-frame run in.github/workflows/desktop-spike.yml. The UI tests don’t run against it. -
The Nucleus window (tao title bar, menus, tray, file dialogs) isn’t tested. The UI tests render the composables offscreen.
-
Window screens not covered: moving a note between notebooks, pinning from the menu in the trash, emptying the trash and its Undo, renaming a title by hand, Cmd/Ctrl+N and Cmd/Ctrl+F, the dark theme, and a window narrower than about 1100 dp (the columns don’t adapt yet).
-
Saving when the window closes (
Launch.flushBeforeExit) is untested. -
Headings, lists, quotes, links, IME composition and paste are untested in the desktop editor.
-
The ComposeTextEditor spike is driven through the library’s state API (
insertTypedString), not real key events or IME. Native image, typing speed on large notes and the library’s context-menu Undo are untested. -
Screenshots: no desktop screenshot tests (Roborazzi supports desktop). The window was only looked at in light and dark by rendering it offscreen once.
Editor, formatting, checklists, saving and titles
The note editor (EditorSession/EditorEngine in the shared core, EditText on Android, UITextView on iOS), autosave and note titles. Decisions: D-08, D-12, D-14, D-22, D-23, D-28, D-30.
Formatting and structure
- Bold on a selection
- Shared:
EditorSessionTest.boldSelection - Android UI:
NoteEditingUiTest.formattingToolShowsItsState - iOS UI:
NoteEditingUITests.FormattingToolShowsItsState
- Shared:
- Bold with nothing selected applies to what’s typed next
- Shared:
EditorSessionTest.boldWithEmptySelectionAppliesToTypedText - Not tested: Android UI, iOS UI
- Shared:
- Typing inside or right after styled text keeps the style
- Shared:
EditorSessionTest.typingInsideAndAfterStyledTextInheritsStyle - Not tested: Android UI, iOS UI
- Shared:
- Italic, strikethrough, inline code
- Shared:
EditorSessionTest.inlineCodeStrikeItalic - Not tested: Android UI, iOS UI
- Shared:
- The formatting tool shows which styles are on
- Android UI:
NoteEditingUiTest.formattingToolShowsItsState - iOS UI:
NoteEditingUITests.FormattingToolShowsItsState - Not tested: Shared
- Android UI:
- Heading levels cycle; Enter after a heading starts a paragraph; Enter at its start keeps it
- Shared:
EditorSessionTest.headingCycles,EditorSessionTest.enterAfterHeadingStartsParagraph,EditorSessionTest.enterAtStartOfHeadingKeepsHeading - Not tested: Android UI, iOS UI
- Shared:
- Numbered and bulleted lists, quotes, code blocks
- Shared:
EditorSessionTest.numberedAndBulletAndQuoteAndCodeBlock - Not tested: Android UI, iOS UI
- Shared:
- Backspace at the start of a list item removes the list formatting
- Shared:
EditorSessionTest.backspaceAtListStartRemovesListFormatting - Not tested: Android UI, iOS UI
- Shared:
- Pasting several lines makes several paragraphs
- Shared:
EditorSessionTest.multiLinePasteSplitsParagraphs - Not tested: Android UI, iOS UI
- Shared:
- Deleting across paragraphs merges them and keeps the first one’s block type
- Shared:
EditorSessionTest.deletingAcrossParagraphsMergesAndKeepsFirstBlock - Not tested: Android UI, iOS UI
- Shared:
- Undo and redo
- Shared:
EditorSessionTest.undoRedo - Android UI:
NoteEditingUiTest.undoRemovesTyping - iOS UI:
NoteEditingUITests.UndoRemovesTyping
- Shared:
Checklists
- Inserting a checklist; Enter continues it
- Shared:
EditorSessionTest.checklistInsertionAndContinuation - Android UI:
NoteEditingUiTest.checklistContinuesOnEnter - iOS UI:
NoteEditingUITests.ChecklistContinuesOnReturn
- Shared:
- Ticking a task changes only its checked state
- Shared:
EditorSessionTest.toggleTaskChangesOnlyCheckedState - Not tested: Android UI, iOS UI
- Shared:
- Indenting makes nested tasks
- Shared:
EditorSessionTest.nestedTasksViaIndent - Not tested: Android UI, iOS UI
- Shared:
- Ticking in the read view saves a TASK_TOGGLE revision and rejects a stale version
- Shared:
NotesCoreTest.taskToggleCreatesRevisionAndRejectsStaleState - Android UI:
NoteEditingUiTest.checklistCanBeTickedInTheReadView - iOS UI:
NoteEditingUITests.ChecklistCanBeTickedInTheReadView
- Shared:
- Task items and completed counts come from the Markdown
- Shared:
MarkdownCodecTest.derivesTaskItems,NotesCoreTest.taskToggleCreatesRevisionAndRejectsStaleState - Not tested: Android UI, iOS UI
- Shared:
Saving
- A new note opens straight in the editor
- Android UI:
NoteEditingUiTest.newNoteOpensStraightInTheEditor - iOS UI:
NoteEditingUITests.NewNoteOpensStraightInTheEditor - Not tested: Shared
- Android UI:
- A written note is saved and shows in the list
- Android UI:
NoteEditingUiTest.writtenNoteIsSavedAndListed - iOS UI:
NoteEditingUITests.WrittenNoteIsSavedAndListed - Not tested: Shared
- Android UI:
- An empty new note is discarded on leaving; one that ever had content is kept
- Shared:
NotesCoreTest.discardIfEmptyRemovesOnlyNotesThatNeverHeldContent - Android UI:
NoteEditingUiTest.emptyNewNoteIsDiscardedOnLeaving - iOS UI:
NoteEditingUITests.EmptyNewNoteIsDiscardedOnLeaving
- Shared:
- Saves once 35 characters have been typed (D-14)
- Android UI:
NoteEditingUiTest.savesOnceThirtyFiveCharactersAreTyped - iOS UI:
NoteEditingUITests.SavesOnceThirtyFiveCharactersAreTyped - Not tested: Shared
- Android UI:
- Each real change makes a revision; saving the same content doesn’t
- Shared:
NotesCoreTest.everySaveCreatesRevisionButNoOpSaveDoesNot - Not tested: Android UI, iOS UI
- Shared:
- Saves within a minute of each other keep only the latest version
- Shared:
NotesCoreTest.savesWithinAMinuteKeepOnlyTheLatestVersion - Not tested: Android UI, iOS UI
- Shared:
- Word and character counts follow typing
- Android UI:
NoteEditingUiTest.wordAndCharacterCountsFollowTyping - iOS UI:
NoteEditingUITests.WordAndCharacterCountsFollowTyping - Not tested: Shared
- Android UI:
Titles
- The title follows the first line until set by hand; clearing it makes it automatic again
- Shared:
OrganizingTest.automaticTitlesFollowTheFirstLineUntilSetByHand - Android UI:
NoteEditingUiTest.titleIsTheFirstLineUntilRenamed - iOS UI:
NoteEditingUITests.TitleIsTheFirstLineUntilRenamed
- Shared:
- A generated title is applied once, then kept
- Shared:
OrganizingTest.aGeneratedTitleIsAppliedOnceAndThenKept - Not tested: Android UI, iOS UI
- Shared:
- A generated title the user asks for replaces a hand-set title
- Shared:
OrganizingTest.aRequestedGeneratedTitleReplacesAHandSetTitle - Not tested: Android UI, iOS UI
- Shared:
- The displayed title falls back to the first line, then “Untitled”
- Shared:
DomainSupportTest.displayTitleFallsBackToFirstLine - Not tested: Android UI, iOS UI
- Shared:
Gaps
- Headings, lists, quotes, code blocks, italic and strikethrough are tested only in the shared session, never through the real
EditText/UITextViewand the toolbar. - The 10-second autosave (D-14) is untested; only the 35-character trigger is.
- Saving when the app goes to the background is untested on both platforms.
- Generated titles: the platform
TitleGenerator(Gemini Nano on Android, Apple Foundation Models on iOS) has no tests; only the core rules for applying a generated title do.docs/pending.mdnotes Android generation hasn’t been tried on a device with the model. - Paste on a device (rich or multi-line text from another app) is untested; only the shared paragraph split is.
Markdown export and import
One-time export to a folder and import of Markdown files: file names, front matter, links between files and notebook folders. Decisions: D-34, D-36, D-42. Folder sync is still a plan (docs/spikes/folder-sync.md) and has no tests.
- Export lays out files (default notebook at the root, a folder per notebook, trashed notes left out); import recreates notes and notebooks; re-import skips unchanged files and adds IMPORT revisions for changed ones
- Shared:
NotesCoreTest.exportImportRoundTrip - Android:
ExportIntegrationTest.exportedFilesKeepNoteLinksAndImportBackIntoTheSameNotes - iOS:
ExportIntegrationTests.ExportedFilesKeepNoteLinksAndImportBackIntoTheSameNotes
- Shared:
- Export from Settings writes one file per note, note links as relative links
- Android:
ExportUiTest.exportWritesEachNoteAsAFileWithNoteLinksAsRelativeLinks - iOS:
ExportUITests.ExportWritesEachNoteAsAFileWithNoteLinksAsRelativeLinks - Not tested: Shared
- Android:
- Importing a folder from Settings adds its notes, turns subfolders into notebooks and shows a summary
- Android:
ImportUiTest.importingAFolderAddsItsNotesAndNotebooks - iOS:
ImportUITests.ImportingAFolderAddsItsNotesAndNotebooks - Not tested: Shared
- Android:
- Note links become relative file links and import reads them back (D-36)
- Shared:
NotesCoreTest.exportWritesNoteLinksAsRelativeLinksAndImportReadsThemBack - Android:
ExportIntegrationTest.exportedFilesKeepNoteLinksAndImportBackIntoTheSameNotes - iOS:
ExportIntegrationTests.ExportedFilesKeepNoteLinksAndImportBackIntoTheSameNotes
- Shared:
- Files are named after titles and kept apart when titles clash (D-42)
- Shared:
NotesCoreTest.exportNamesFilesAfterTitlesAndKeepsThemApart - Not tested: Android, iOS
- Shared:
- File name cleanup: unsafe characters, empty names, titles from paths, folders from paths
- Shared:
DomainSupportTest.pathsAndFileNames - Not tested: Android, iOS
- Shared:
- Front matter round-trips: ids, title, dates, pinned
- Shared:
DomainSupportTest.frontMatterRoundTrip - Not tested: Android, iOS
- Shared:
- Invalid front matter keeps the whole file as the body, with a warning
- Shared:
DomainSupportTest.invalidFrontMatterKeepsWholeFile - Not tested: Android, iOS
- Shared:
- Pins are exported and restored on import
- Shared:
NotesCoreTest.exportKeepsPinsAndImportRestoresThem - Not tested: Android, iOS
- Shared:
- Re-import keeps an automatic title unless the file changes it
- Shared:
NotesCoreTest.reimportKeepsAnAutomaticTitleUnlessTheFileChangesIt - Not tested: Android, iOS
- Shared:
- Files without front matter are still linked to each other on import
- Shared:
NotesCoreTest.importLinksFilesWithoutFrontMatter - Not tested: Android, iOS
- Shared:
- Plain Markdown files (from other apps) import, with titles and notebooks from paths
- Shared:
NotesCoreTest.importPlainMarkdownFiles - Not tested: Android, iOS
- Shared:
- Exported names are Unicode NFC
- Shared:
NotesCoreTest.exportWritesComposedNames - iOS:
ExportIntegrationTests.ExportWritesComposedUnicodeNames - Not tested: Android
- Shared:
- Decomposed (NFD) file names match composed links and notebooks on import
- Shared:
NotesCoreTest.importMatchesDecomposedFileNamesToComposedLinksAndNotebooks - Not tested: Android, iOS
- Shared:
- Settings explain where notes live
- Android:
HistorySearchTrashUiTest.settingsExplainWhereNotesLive - iOS:
HistorySearchTrashUITests.SettingsExplainWhereNotesLive - Not tested: Shared
- Android:
The system pickers are replaced in UI tests (Espresso-Intents on Android, JOTRY_EXPORT_FOLDER and JOTRY_IMPORT_FOLDER on iOS); see CONTRIBUTING.md.
Gaps
- Importing single files from Settings (Android’s Import Markdown files, picking files rather than a folder on iOS) and the unreadable-file warning have no UI test.
- The real folder picker is never driven, by design.
- Importing into a chosen notebook (
targetNotebookIdnot null) is untested; both apps pass null today. - Re-importing an edited export that conflicts with newer changes in the app is untested beyond titles.
- Android: no test for NFC file names on a real Android file system (iOS has one).
History, versions and changes
Revisions, restoring and copying versions, and the change view between versions. Decisions: D-04, D-37, D-38, D-43.
Revisions
- Every real save makes a revision linked to its parent; a no-op save doesn’t
- Shared:
NotesCoreTest.everySaveCreatesRevisionButNoOpSaveDoesNot - Not tested: Android UI, iOS UI
- Shared:
- Saves within a minute collapse into the latest version
- Shared:
NotesCoreTest.savesWithinAMinuteKeepOnlyTheLatestVersion - Not tested: Android UI, iOS UI
- Shared:
- History lists versions; restoring one adds a RESTORE revision and keeps the rest
- Shared:
NotesCoreTest.restoreCreatesNewRevisionAndKeepsHistory - Android UI:
HistorySearchTrashUiTest.historyListsVersionsAndRestoresOne - iOS UI:
HistorySearchTrashUITests.HistoryListsVersionsAndRestoresOne
- Shared:
- Copying a version makes a new note “(copy)” in the same notebook
- Shared:
NotesCoreTest.copyRevisionToNewNote - Android UI:
HistorySearchTrashUiTest.copyingAVersionMakesANewNote - iOS UI:
HistorySearchTrashUITests.CopyingAVersionMakesANewNote
- Shared:
- A version’s changes compare with the version before it, even across a hidden move
- Shared:
NotesCoreTest.versionChangesCompareWithTheVersionBefore - Not tested: Android UI, iOS UI
- Shared:
- Move, delete and undelete are stored as revisions but left out of history; restoring a version doesn’t move the note (D-43)
- Shared:
NotesCoreTest.moveDeleteUndelete,NotesCoreTest.savesWithinAMinuteKeepOnlyTheLatestVersion,OrganizingTest.deletingANotebookMovesItsNotesToTheTopLevelAndUndoPutsThemBack - Android UI:
HistorySearchTrashUiTest.historyListsVersionsAndRestoresOne(a move only; the note is moved, then a checklist tick after it shows as the current version) - iOS UI:
HistorySearchTrashUITests.HistoryListsVersionsAndRestoresOne(same) - Not tested: Android UI, iOS UI (delete and undelete; the newest shown version being marked current when a move is the latest revision)
- Shared:
Change view (RevisionDiffTest)
| Behavior | Shared |
|---|---|
| The first version shows everything as added (D-38) | firstVersionIsAllAdded |
| A word change is shown with its line | showsAWordChangeWithItsLine |
| Neighboring word changes join | joinsAdjacentWordChanges |
| Changes on distant lines are separate hunks | separatesChangesOnDistantLines |
| Changes on neighboring lines stay in one hunk | keepsChangesOnNeighbouringLinesTogether |
| Context is capped at about 100 characters per side (D-38) | capsContextAtAboutAHundredCharacters |
| Added and removed lines | showsAddedAndRemovedLines |
| Formatting alone is not a change (D-37) | formattingAloneIsNotAChange |
| Checking a task is a change | checkingATaskIsAChange |
| A rename is not a change | aRenameIsNotAChange |
| Whitespace-only changes are shown | showsWhitespaceOnlyChanges |
| Large rewrites are handled (edit cap) | handlesLargeRewrites |
The earlier-version screen is in screenshots.md (earlierVersion on both platforms).
Gaps
- Accessibility of the change view: screen readers hearing “added:” and “removed:”, and removed text being struck through as well as red, are untested. Only the screenshots show the look.
- “Formatting changes only” label on a version with no text changes has no UI test.
- History of a trashed note (viewing or restoring from the trash) is untested.
Note links, web links, lookup and back/forward
Links between notes (jotry://note/<id>), web addresses, the fuzzy note lookup, and back/forward through visited notes. Decisions: D-17, D-20, D-25, D-29, D-36, D-39.
Links
- Typing
[[picks a note, inserts a link, and tapping the link opens that note- Android UI:
NoteEditingUiTest.typingDoubleBracketLinksToANoteAndTheLinkOpensIt - iOS UI:
NoteEditingUITests.TypingDoubleBracketLinksToANoteAndTheLinkOpensIt - Not tested: Shared
- Android UI:
- When no note matches, the link lookup creates one with the typed title and links to it
- Shared:
OrganizingTest.aNoteCreatedFromALinkKeepsItsTitleAndIsKept - Android UI:
NoteEditingUiTest.linkLookupCreatesTheNoteItLinksTo - iOS UI:
NoteEditingUITests.LinkLookupCreatesTheNoteItLinksTo
- Shared:
- The link tool turns a web address into a link
- Shared:
EditorSessionTest.linkOnSelectionAndInsertion - Android UI:
NoteEditingUiTest.linkToolTurnsAWebAddressIntoALink - iOS UI:
NoteEditingUITests.LinkToolTurnsAWebAddressIntoALink
- Shared:
- Typing right after a link doesn’t extend it
- Shared:
EditorSessionTest.typingAfterLinkDoesNotExtendIt - Not tested: Android UI, iOS UI
- Shared:
- Web addresses are recognized
- Shared:
NoteLinksTest.recognisesWebAddresses - Not tested: Android UI, iOS UI
- Shared:
- Plain-text web addresses become links in read-only views (D-29)
- Shared:
NoteLinksTest.plainTextWebAddressesBecomeLinksInTheView - Not tested: Android UI, iOS UI
- Shared:
- A closing bracket that belongs to the address is kept
- Shared:
NoteLinksTest.aClosingBracketThatIsPartOfTheAddressIsKept - Not tested: Android UI, iOS UI
- Shared:
- Note link addresses round-trip
- Shared:
NoteLinksTest.noteLinksRoundTrip - Not tested: Android UI, iOS UI
- Shared:
- Custom labels are marked in the address (
?label=custom)- Shared:
NoteLinksTest.customLabelsAreMarkedInTheAddress - Not tested: Android UI, iOS UI
- Shared:
- Labels follow the target’s title unless custom (D-39)
- Shared:
NoteLinksTest.labelsFollowTheTargetTitleUnlessCustom,EditorSessionTest.noteLinksFollowTheTitleUnlessTheUserChoseTheWords,NotesCoreTest.noteLinkLabelsFollowRenamesUntilTheUserChoosesTheWords - Not tested: Android UI, iOS UI
- Shared:
- Picking a new target for a following link relabels it
- Shared:
EditorSessionTest.choosingANewTargetForAFollowingLinkRelabelsIt - Not tested: Android UI, iOS UI
- Shared:
- Typing inside a label makes it custom
- Shared:
EditorSessionTest.typingInsideALabelMakesItCustom - Not tested: Android UI, iOS UI
- Shared:
- Relabelling keeps the styles around and after the link
- Shared:
NoteLinksTest.relabellingKeepsStylesAroundAndAfterTheLink - Not tested: Android UI, iOS UI
- Shared:
Links in exported files are in export-import.md.
Lookup and back/forward
- The lookup ranks title matches first and skips trashed notes
- Shared:
OrganizingTest.lookupRanksTitleMatchesFirstAndSkipsTrash - Not tested: Android UI, iOS UI
- Shared:
- Find note jumps to a note; swipes move back and forward
- Shared:
NoteTrailTest.movesBackAndForwardLikeABrowser - Android UI:
NoteEditingUiTest.findNoteJumpsAndSwipesMoveThroughTheTrail - iOS UI:
NoteEditingUITests.FindNoteJumpsAndSwipesMoveThroughTheTrail
- Shared:
- A deleted note drops out of the trail and its neighbors merge
- Shared:
NoteTrailTest.forgettingADeletedNoteSkipsItAndMergesNeighbours - Not tested: Android UI, iOS UI
- Shared:
Gaps
- A link to a trashed or purged note (what tapping it does) is untested.
- Tapping a plain web address in the read view opening the browser is untested in the UI.
- Back/forward skipping a deleted note is tested only in the shared trail, not in the apps.
Markdown parsing and writing
MarkdownCodec turns stored Markdown into the editor’s blocks and spans and back. It has no UI of its own; the UI tests exercise it indirectly. Decisions: D-02, D-08, D-13.
| Behavior | Shared (MarkdownCodecTest) |
|---|---|
| Blocks parse into text and block types without Markdown syntax showing | parsesBlocksWithoutExposingSyntax |
| Writing produces canonical Markdown | serializesCanonicalMarkdown |
| Bold, italic, strikethrough, code and links survive a round trip | inlineStylesRoundTrip |
Long runs of * or _ are emphasis, not literal text | longDelimiterRunsAreEmphasisNotText |
| Literal Markdown characters typed as text are escaped | escapesLiteralMarkdownCharacters |
Text that looks like a numbered item (1.) at a paragraph start is escaped | numberedParagraphTextIsEscaped |
| Empty paragraphs between content are preserved | emptyParagraphsBetweenContentArePreserved |
| A literal non-breaking space in text is escaped | literalNbspTextIsEscaped |
| Unsupported syntax (tables, horizontal rules, HTML) is kept verbatim as raw blocks | unsupportedSyntaxIsPreservedVerbatim |
| Writing what was parsed gives the same Markdown again (fixed point) | serializationIsAFixedPoint |
| An empty document | emptyDocument |
| Task items and their checked state are derived | derivesTaskItems |
Related: search matches visible text, not syntax (NotesCoreTest.searchMatchesVisibleTextNotSyntax, see search.md); diffs compare visible text (see history.md).
Gaps
- Mermaid fences (D-13: imported, shown as code, exported unchanged) have no test of their own.
- Images and footnotes are not checked to survive a save (tables, rules and HTML are).
- Nested lists deeper than one level and numbered-list renumbering (
RichText.listNumbers) have no direct test. - Very large notes (parse and write time) are untested.
Notebooks, pins and moving notes
Notebooks, the default notebook, archiving, ordering, pins, multi-select moves and undo. Decisions: D-03, D-15, D-16, D-18, D-19, D-24.
- An empty app shows the “clean desk” home with New note and Search
- Android UI:
NotebookUiTest.startsWithAClearDesk - iOS UI:
NotebookUITests.StartsWithAClearDesk - Not tested: Shared
- Android UI:
- The default notebook always exists, is called “Default” and can’t be archived
- Shared:
NotesCoreTest.inboxExistsAndCannotBeArchived,OrganizingTest.theDefaultNotebookIsCalledDefault - Not tested: Android UI, iOS UI
- Shared:
- Creating, renaming (trimmed), archiving, unarchiving and reordering notebooks
- Shared:
NotesCoreTest.notebookLifecycle - Android UI:
NotebookUiTest.createsNotebooksAsNumberedVolumes,NotebookUiTest.renamesNotebook,NotebookUiTest.reordersNotebooks - iOS UI:
NotebookUITests.CreatesNotebookAsFirstVolume,NotebookUITests.RenamesNotebook,NotebookUITests.ReordersNotebooks
- Shared:
- Archived notebooks are hidden until “show archived” is on
- Shared:
NotesCoreTest.notebookLifecycle - Android UI:
NotebookUiTest.archivedNotebooksAreHiddenUntilShown - iOS UI:
NotebookUITests.ArchivedNotebooksAreHiddenUntilShown
- Shared:
- A note made inside a notebook belongs to it and counts toward it
- Android UI:
NotebookUiTest.newNoteInANotebookCountsTowardIt - iOS UI:
NotebookUITests.NewNoteInANotebookCountsTowardIt - Not tested: Shared
- Android UI:
- A note made at the top level stays in the default notebook
- Android UI:
NotebookUiTest.newNoteFromTheTopLevelStaysThere - iOS UI:
NotebookUITests.NewNoteFromTheTopLevelStaysThere - Not tested: Shared
- Android UI:
- Deleting a notebook moves its notes to the top level; undo puts them back
- Shared:
OrganizingTest.deletingANotebookMovesItsNotesToTheTopLevelAndUndoPutsThemBack - Android UI:
NotebookUiTest.deletingANotebookKeepsItsNotesAndUndoBringsItBack - iOS UI:
NotebookUITests.DeletingANotebookKeepsItsNotesAndUndoBringsItBack
- Shared:
- Pinning isn’t an edit (no revision) and pinned notes list first
- Shared:
OrganizingTest.pinningIsNotAnEditAndPinnedNotesComeFirst - Android UI:
NotebookUiTest.pinnedNotesShowFirst - iOS UI:
NotebookUITests.PinnedNotesShowFirst
- Shared:
- A new note pinned from the editor is listed with its title (the home list’s lazy stack used to keep the card it had under Recent notes, empty and unpinned)
- iOS UI:
NotebookUITests.ANewNotePinnedWhileEditingIsListedWithItsTitle - Not tested: Android UI (checked by hand on a phone, 2026-10-11; Compose keeps no stale row)
- iOS UI:
- Moving a single note records a MOVE revision
- Shared:
NotesCoreTest.moveDeleteUndelete - Not tested: Android UI, iOS UI
- Shared:
- Selected notes move together; undo restores their old places
- Shared:
OrganizingTest.bulkMovesAndDeletesCanBeUndone - Android UI:
NotebookUiTest.selectedNotesMoveTogetherAndUndoPutsThemBack - iOS UI:
NotebookUITests.SelectedNotesMoveTogetherAndUndoPutsThemBack
- Shared:
- Cancelling a selection changes nothing
- Android UI:
NotebookUiTest.cancellingSelectionChangesNothing - iOS UI:
NotebookUITests.CancellingSelectionChangesNothing - Not tested: Shared
- Android UI:
- The drawer opens a notebook and marks it as current
- Android UI:
NotebookUiTest.drawerOpensANotebookAndMarksIt - iOS UI:
NotebookUITests.DrawerOpensANotebookAndMarksIt - Not tested: Shared
- Android UI:
- Swiping from the left edge at home opens the drawer
- iOS UI:
NotebookUITests.SwipeFromTheLeftEdgeOpensTheDrawerAtHome - Not tested: Shared, Android UI
- iOS UI:
Selecting search results and trash items is in search.md and trash.md.
Gaps
- Android: no test for opening the drawer with a left-edge swipe (iOS has one).
- Select all in a note list (D-18) has no test on any layer.
- Pin order among pinned notes (most recently pinned first, D-19) is not checked beyond “pinned before unpinned”.
- Moving into an archived notebook, or archiving the notebook that is open, has no test.
Screenshots
Screenshot tests compare each screen with a committed reference image. They catch changes the UI tests don’t: colors, fonts, spacing, dark mode. All of them use the shared sample notes (SampleNotes.kt). Recording and comparing are described in CONTRIBUTING.md.
| Screen | Android (ScreenshotTest) | iOS (ScreenSnapshotTests) |
|---|---|---|
| Home | home | Home |
| Home, dark | homeDark | HomeDark |
| Notebooks drawer | notebooksDrawer | NotebooksDrawer |
| A notebook | notebook | Notebook |
| Note read view | noteReadView | NoteReadView |
| Note read view, dark | noteReadViewDark | NoteReadViewDark |
| Note editor | noteEditor | NoteEditor |
| History | history | History |
| An earlier version with changes | earlierVersion | EarlierVersion |
| Search, empty | — | Search |
| Search results | searchResults | SearchResults |
| Trash | trash | Trash |
| Settings | settings | Settings |
References: app/androidApp/src/test/screenshots and app/iosApp/JotrySnapshotTests/__Snapshots__.
Gaps
- Android: no screenshot of the empty search screen (iOS has one).
- Dark mode is covered only for home and the note read view.
- Large text sizes (Dynamic Type, Android font scale) and landscape or tablet layouts have no screenshots.
- Languages other than English have no screenshots.
- Dialogs and menus (selection bar, More menu, move-to sheet, undo bar) have no screenshots.
Search
Full-text search, match highlighting and selecting results. Decisions: D-41.
- Finds notes by text
- Shared:
NotesCoreTest.searchResultsCarryWhereTheTermsMatch - Android UI:
HistorySearchTrashUiTest.searchFindsNotesByText - iOS UI:
HistorySearchTrashUITests.SearchFindsNotesByText
- Shared:
- No matches says so
- Android UI:
HistorySearchTrashUiTest.searchWithoutMatchesSaysSo - iOS UI:
HistorySearchTrashUITests.SearchWithoutMatchesSaysSo - Not tested: Shared
- Android UI:
- Matches visible text, not Markdown syntax
- Shared:
NotesCoreTest.searchMatchesVisibleTextNotSyntax - Not tested: Android UI, iOS UI
- Shared:
- Trashed notes aren’t found
- Shared:
NotesCoreTest.moveDeleteUndelete - Not tested: Android UI, iOS UI
- Shared:
- Every occurrence is found, ignoring case
- Shared:
SearchIndexTest.findsEveryOccurrenceIgnoringCase - Not tested: Android UI, iOS UI
- Shared:
- Each term of the query is matched
- Shared:
SearchIndexTest.findsEachTermOfTheQuery - Not tested: Android UI, iOS UI
- Shared:
- Overlapping and touching matches merge
- Shared:
SearchIndexTest.mergesOverlappingAndTouchingMatches - Not tested: Android UI, iOS UI
- Shared:
- Punctuation matches literally
- Shared:
SearchIndexTest.matchesPunctuationLiterally - Not tested: Android UI, iOS UI
- Shared:
- Match offsets index the original text even when lower-casing changes length
- Shared:
SearchIndexTest.offsetsIndexTheOriginalTextEvenWhereLowerCasingChangesLength - Not tested: Android UI, iOS UI
- Shared:
- An empty query matches nothing
- Shared:
SearchIndexTest.nothingMatchesAnEmptyQuery - Not tested: Android UI, iOS UI
- Shared:
- Selected results move together
- Android UI:
HistorySearchTrashUiTest.selectedSearchResultsMoveTogether - iOS UI:
HistorySearchTrashUITests.SelectedSearchResultsMoveTogether - Not tested: Shared
- Android UI:
- Selected results go to the trash together
- Android UI:
HistorySearchTrashUiTest.selectedSearchResultsGoToTheTrashTogether - iOS UI:
HistorySearchTrashUITests.SelectedSearchResultsGoToTheTrashTogether - Not tested: Shared
- Android UI:
The note lookup (inside a note) is in links-and-navigation.md.
Gaps
- Select all in search results, and undo after moving results, are untested.
- Results updating after a note is edited or deleted while search is open are untested.
- Non-Latin text (accents, Cyrillic, CJK) in queries is untested beyond the lower-casing offset case.
Sync server
The server in server/ (D-33, D-45, D-60, D-61). Business logic and its interfaces are in server/service and tested with the JVM alone. The Postgres adapter in server/postgres is tested against a real Postgres: a fresh database per test, on a Testcontainers Postgres 17 or on the one JOTRY_TEST_DATABASE_URL names (TestDatabases, in the module’s test fixtures). The Ktor adapter and the Koin wiring in server/src are tested through Ktor’s test host. The development verifier in server/dev has its own tests. Store implementations pass a shared contract (GroupStoreContract, RecordStoreContract, AuthStoreContract and PromoStoreContract, in server/service’s test fixtures), run once against the in-memory store and once against Postgres. The API classes in :syncApi are tested on the JVM, the iOS simulator and an Android emulator. Right now the behavior is the health check, the start-up migration, groups with their entitlements, created from a verified purchase (D-70) or a promo code (D-72), records with the conditional put and the change feed (D-65), the sync routes over them (K1), device auth with QR enrollment and recovery (epic 4, D-67, D-68), and the deletion of groups a year after they lapsed (D-56); the store verifiers and the reverse QR flow are not built (see the cloud sync epics in pending.md).
| Behavior | Service | Postgres adapter | Ktor adapter and wiring |
|---|---|---|---|
| With no probes the server reports ok | HealthServiceTest.withNoProbesTheServerIsOk | ||
| All passing probes report ok, with each probe’s result | HealthServiceTest.allPassingProbesAreOk | ApplicationTest.healthIsOkWhenEveryProbePasses | |
| One failing probe makes the report not ok | HealthServiceTest.oneFailingProbeMakesTheReportNotOk | ApplicationTest.healthIs503WhenAProbeFails | |
| A probe that throws counts as failing | HealthServiceTest.aProbeThatThrowsCountsAsFailing | ||
| The database probe passes while Postgres answers | PostgresDatabaseTest.theHealthProbePassesWhilePostgresAnswers | WiringTest.healthReportsTheDatabase | |
| The health report fails once the database is gone | PostgresDatabaseTest.theHealthReportFailsOnceTheDatabaseIsGone | WiringTest.healthReportsTheDatabase (503) | |
| Migrations apply to an empty database, none failed | PostgresDatabaseTest.migrateAppliesEveryMigrationToAnEmptyDatabase | ||
| Migrating again runs nothing | PostgresDatabaseTest.migratingAgainRunsNothing | ||
| Starting the server migrates the database | WiringTest.startingTheServerMigratesTheDatabase | ||
Connection settings come from DATABASE_*; a missing or blank one is named; the password stays out of toString | PostgresSettingsTest (3 tests) | ||
/ answers with the template greeting | ApplicationTest.testRoot |
Sync routes (K1: D-63, D-65; behind device tokens since epic 4)
SyncRoutesTest runs the routes in Ktor’s test host over the in-memory stores and a test clock; WiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres runs them through the production wiring on Postgres.
| Behavior | Test |
|---|---|
Without the development verifier (an ordinary build) a development purchase, or one from a store with no verifier, is 422 unsupported-source | SyncRoutesTest.withoutTheDevelopmentVerifierADevelopmentPurchaseIsRefused |
A proof the verifier rejects is 422 invalid-purchase; one bought for another group is 422 purchase-for-another-group | SyncRoutesTest.aGroupNeedsAPurchaseMadeForIt |
A group is created once (201); the same id again is 409 group-exists | AuthRoutesTest.aGroupNeedsItsFirstDeviceToProveItsKey |
| A put is conditional on the seq last seen: 200 with the new seq, 409 with the current one | SyncRoutesTest.aPutIsConditionalOnTheSeqLastSeen |
| Changes come in seq order with kind, note, author device, server time and envelope, as CBOR | SyncRoutesTest.changesReturnTheRecordsInSeqOrderWithTheirMetadata |
Changes come in pages of limit, with hasMore and the next cursor | SyncRoutesTest.changesComeInPages |
| A delete is a tombstone in the changes | SyncRoutesTest.aDeleteIsATombstoneInTheChanges |
| A write that breaks a rule is 422 with the refusal as its code | SyncRoutesTest.aWriteThatBreaksARuleIs422WithItsReason |
| A record is attributed to the signed-in device | SyncRoutesTest.changesComeInSeqOrderWrittenByTheSignedInDevice |
A lapsed group is 403 read-only for put and still pulls; an expired one cannot get a session (410) | SyncRoutesTest.aLapsedGroupIsReadOnlyThenExpired |
| A body that is not CBOR is 415; bad CBOR, a bad UUID or a negative cursor is 400 | SyncRoutesTest.malformedRequestsAreRefused |
| Group, sign-in, put, changes and device removal work through the production wiring on Postgres | WiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres |
Groups and entitlements (D-52 to D-56)
| Behavior | Service | Postgres adapter |
|---|---|---|
| An active entitlement makes the group entitled until it ends | EntitlementPolicyTest.anActiveEntitlementMakesTheGroupEntitledUntilItEnds | |
| The latest end among several entitlements wins (D-52) | EntitlementPolicyTest.theLatestEndAmongSeveralEntitlementsWins | |
| A revoked (refunded) entitlement ends when it was revoked | EntitlementPolicyTest.aRevokedEntitlementEndsWhenItWasRevoked | |
| Once all have ended the group is read-only for a year, then expired (D-56) | EntitlementPolicyTest.onceEveryEntitlementHasEndedTheGroupIsReadOnlyForAYear | |
| A year after a leap day ends on 28 February | EntitlementPolicyTest.aYearAfterALeapDayEndsOnTheLastDayOfFebruary | |
| A group with no entitlements counts as lapsed since it was created | EntitlementPolicyTest.aGroupWithNoEntitlementsCountsAsLapsedSinceItWasCreated | |
| Only an entitled group may push; an expired one may not pull | EntitlementPolicyTest.onlyAnEntitledGroupMayPushAndAnExpiredOneMayNotPull | |
| The access service applies the policy to a stored group at the clock’s time | EntitlementPolicyTest.theServiceAppliesThePolicyToAStoredGroupAtTheClocksTime | |
| A created group is found by id and by recovery lookup id, with its recovery fields and first entitlement | InMemoryGroupStoreTest (contract) | PostgresGroupStoreTest (contract) |
| Unknown groups are not found | contract: anUnknownGroupIsNotFound | same |
| Creating with an already bound transaction creates nothing (D-53, D-54) | contract: creatingWithATransactionThatIsAlreadyBoundCreatesNothing | same |
| A group holds entitlements from several stores, oldest binding first | contract: aGroupCanHoldEntitlementsFromSeveralStores | same |
| A transaction belongs to one group only (D-54) | contract: aTransactionBelongsToOneGroupOnly | same |
| The same transaction id from another store is a different transaction | contract: theSameTransactionIdFromAnotherStoreIsADifferentTransaction | same |
| An entitlement for an unknown group is refused | contract: anEntitlementForAnUnknownGroupIsRefused | same |
| Two creates racing for one transaction bind it once | contract: twoGroupsCreatedAtOnceWithTheSameTransactionBindItOnce | same (the loser hits the unique key and its retry finds the winner) |
| A group is deleted a year after its last entitlement ended (a refund ends it early; a later entitlement keeps it), exactly when the policy says, across a leap day (D-56) | contract: aGroupIsDeletedAYearAfterItsLastEntitlementEnded, deletionAgreesWithThePolicyAcrossALeapDay | same (one DELETE; the foreign keys take records, devices, tokens and enrollments with it) |
| A store merges a transaction the group holds: the expiry only moves later, a refund keeps its earliest time, and only the holding group can merge it (D-71) | contract: aRenewalMovesTheExpiryLaterButNeverEarlier, aRevocationStaysAtItsEarliestTime, onlyTheGroupThatHoldsATransactionCanMergeIt | same (one UPDATE with GREATEST and LEAST) |
| The maintenance job deletes a group only once it is due | MaintenanceTest.aGroupIsDeletedAYearAfterItLapsed |
Creating a group and adding purchases (D-54, D-58, D-70, D-71)
| Behavior | Service (GroupCreationTest) | Routes and builds |
|---|---|---|
| A purchase gives the new group its entitlement (source, transaction, end), and the group its first device | aGroupIsEntitledByItsPurchaseAndHasItsFirstDevice | AuthRoutesTest.aGroupNeedsItsFirstDeviceToProveItsKey |
| A source without a verifier is refused, including development purchases on an ordinary build | aSourceWithoutAVerifierIsNotAccepted | SyncRoutesTest.withoutTheDevelopmentVerifierADevelopmentPurchaseIsRefused |
| A proof the store rejects creates nothing | aProofTheStoreRejectsCreatesNothing | SyncRoutesTest.aGroupNeedsAPurchaseMadeForIt |
| The purchase’s account token must be the new group’s id (D-54) | thePurchaseMustNameTheNewGroup | SyncRoutesTest.aGroupNeedsAPurchaseMadeForIt |
| An expired or refunded purchase creates nothing | anEndedPurchaseCreatesNothing | |
| A purchase creates one group only | aPurchaseCreatesOneGroupOnly | |
| One verifier per source | twoVerifiersForOneSourceAreAMistake | |
| The development verifier takes a group id as a one-year purchase for that group, and nothing else | DevelopmentVerifierTest (2 tests) | |
| The development verifier is found on the classpath | DevelopmentVerifierTest.theServiceLoaderFindsIt; WiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres (production wiring, test classpath) | |
| A signed-in device adds a purchase from another store; a renewal or refund updates the row the group holds; a lapsed group reattaches; a stale proof changes nothing | PurchasesTest (5 tests) | SyncRoutesTest.aSignedInDeviceAddsAPurchaseOrUpdatesOneTheGroupHolds (200 with added and the new end) |
| An added purchase must be for the signed-in group, not another group’s transaction, supported, valid and not ended | PurchasesTest.thePurchaseMustBeForTheSignedInGroup, aTransactionOfAnotherGroupIsRefused, unsupportedInvalidAndEndedPurchasesAddNothing | SyncRoutesTest.aSignedInDeviceAddsAPurchaseOrUpdatesOneTheGroupHolds (422, 401) |
An ordinary build does not contain server/dev | Gradle task :server:checkNoDevelopmentVerifier (part of check, installDist, distZip, distTar) |
Promo codes (D-72)
| Behavior | Service | Stores (PromoStoreContract: InMemoryPromoStoreTest, PostgresPromoStoreTest) | Routes and tool |
|---|---|---|---|
A code is JOTRY- and two groups of five Crockford base-32 characters; reading one back forgives case, spacing, a missing prefix and O, I, L for 0, 1, 1; anything else is not a code | PromoCodesTest (3 tests) | SyncRoutesTest.aPromoCodeCreatesAGroupOnce (sent in lower case) | |
| A code is stored as its hash with its days, uses, deadline and note, listed newest first | anAddedCodeIsListedAsItWasAddedNewestFirst | PromoToolTest.createPrintsCodesWhoseHashesAreStored | |
| A redemption lasts the code’s days from when it was redeemed and uses one use; an unknown code is refused | PromoVerifierTest.aCodeIsAPurchaseForTheGroupRedeemingIt, anUnknownOrMalformedCodeIsInvalid | aRedemptionLastsTheCodesDaysFromWhenItWasRedeemedAndUsesOneUse, anUnknownCodeIsRefused | |
| Redeeming again for the same group answers the first redemption and uses nothing, even after the code is revoked | PromoVerifierTest.aCodeAddedToAGroupExtendsItOnceHoweverOftenItIsSent | redeemingAgainForTheSameGroupAnswersTheFirstRedemptionAndUsesNothing, aRevokedCodeIsRefusedButGroupsThatRedeemedItKeepWhatTheyGot | |
| A code serves as many groups as it has uses, each with its own row | PromoVerifierTest.oneCodeWithSeveralUsesCreatesSeveralGroups | aCodeServesAsManyGroupsAsItHasUses | SyncRoutesTest.aPromoCodeCreatesAGroupOnce (a used-up code is 422 invalid-purchase) |
A code cannot be redeemed from its deadline; --redeem-by covers that whole day in UTC | aCodeCannotBeRedeemedFromItsDeadline | PromoToolTest.aDeadlineCoversTheWholeDayInUtc | |
| A revoked code is refused; revoking twice keeps the first time | aRevokedCodeIsRefusedButGroupsThatRedeemedItKeepWhatTheyGot, revokingTwiceKeepsTheFirstTime | PromoToolTest.revokeStopsACode | |
| Many groups racing for a code’s last use leave one winner; a group racing itself uses one use | manyGroupsRacingForTheLastUseLeaveExactlyOneWinner, oneGroupRacingItselfUsesOneUse | (Postgres: the code’s row is locked FOR UPDATE) | |
| A code creates a group, or is added to one through the purchases route | PromoVerifierTest.aCodeCreatesAGroup, aCodeAddedToAGroupExtendsItOnceHoweverOftenItIsSent | SyncRoutesTest.aPromoCodeCreatesAGroupOnce (on a build without the development verifier) | |
| The tool lists uses and state, and refuses bad arguments with its usage, creating nothing | PromoToolTest.listShowsUsesAndState, badArgumentsPrintUsageAndCreateNothing |
Records (D-65)
| Behavior | Service (RecordServiceTest) | Stores (RecordStoreContract: InMemoryRecordStoreTest, PostgresRecordStoreTest) |
|---|---|---|
| A new record gets the group’s next seq | aNewRecordIsStoredWithTheGroupsNextSeq | aPutStoresTheRecordWithTheGroupsNextSeq (with every stored field) |
| A stale seq is a conflict naming the current seq; 0 means “create” | aStaleSeqIsAConflictThatNamesTheCurrentSeq | aPutWithAStaleSeqIsRefusedWithTheCurrentSeq, aPutToAMissingRecordThatExpectsOneIsAMismatch |
| Each group counts on its own | eachGroupCountsOnItsOwn | |
| Only a notebook has no note id | onlyANotebookHasNoNoteId | (also a CHECK constraint in V3__records.sql) |
| A record keeps its kind and its note | aRecordKeepsItsKindAndItsNote | |
| A revision is written once and afterwards can only be deleted | aRevisionIsWrittenOnceAndAfterwardsCanOnlyBeDeleted | |
| A deleted notebook can be written again | aDeletedNotebookCanBeWrittenAgain | |
| A delete needs a record that is not deleted yet | aDeleteNeedsARecordThatIsNotDeletedYet | |
| A tombstone has no envelope and is in the feed | aTombstoneHasNoEnvelopeAndIsInTheFeed | |
| A negative seq is refused | aNegativeSeqIsRefused | |
| An unknown group is reported, for put and changes | anUnknownGroupIsReported | |
| A lapsed group may pull but not push; an expired one may not pull (D-56) | aLapsedGroupMayPullButNotPush, anExpiredGroupMayNotPull | |
| The feed lists each record once at its latest seq, in order, after the cursor | theFeedListsEachRecordOnceAtItsLatestSeqInOrder | |
| The feed pages, says when more is waiting, and keeps the cursor when empty | theFeedReturnsPagesAndSaysWhenMoreIsWaiting, anEmptyPageKeepsTheCursor | |
| Of concurrent puts to one record exactly one wins | ofConcurrentPutsToOneRecordExactlyOneWins | |
| Concurrent puts in one group get every seq once | concurrentPutsInOneGroupGetEverySeqOnce |
Auth, devices, enrollment and recovery (epic 4: D-55, D-67, D-68)
| Behavior | Service | Stores (AuthStoreContract: InMemoryAuthStoreTest, PostgresAuthStoreTest) | Routes (AuthRoutesTest, SyncRoutesTest) |
|---|---|---|---|
| A signed challenge buys a 15-minute token; it expires on time | AuthServiceTest.aSignedChallengeBuysATokenForFifteenMinutes | aTokenGivesASessionUntilItExpires | SyncRoutesTest.recordRoutesNeedAValidTokenForTheirGroup |
| Only the token’s SHA-256 is stored | AuthServiceTest.onlyTheTokensHashIsStored | ||
| A challenge is used once, even after a wrong signature; replays are refused | AuthServiceTest.aChallengeIsUsedOnce, aWrongSignatureIsRefusedAndBurnsTheChallenge | aChallengeIsTakenOnce, ofConcurrentTakesOfOneChallengeExactlyOneWins | AuthRoutesTest.aChallengeIsUsedOnceAndOnlyBySignatureOfTheDeviceItWasIssuedTo |
| A challenge expires after a minute and serves only the device (and purpose) it was issued for | AuthServiceTest.aChallengeExpiresAfterAMinute, aChallengeServesOnlyTheDeviceItWasIssuedFor; RecoveryServiceTest.anAuthChallengeDoesNotServeRecovery | ||
| Unknown devices and groups get no challenge; a lapsed group signs in, an expired one does not | AuthServiceTest.unknownDevicesAndGroupsGetNoChallenge, aLapsedGroupStillSignsInAndAnExpiredOneDoesNot | AuthRoutesTest.anUnknownDeviceGetsNoChallenge, SyncRoutesTest.aLapsedGroupIsReadOnlyThenExpired | |
| Removing a device ends its sessions at once and it cannot sign in again | AuthServiceTest.aRemovedDeviceLosesItsSessionAtOnceAndCannotSignInAgain | revokingADeviceEndsItsSessionsAtOnce | AuthRoutesTest.aRemovedDeviceLosesItsTokenAtOnceAndCannotSignInAgain, WiringTest.deviceAuthAndRecordsWorkThroughTheProductionWiringOnPostgres |
| A device can remove itself but not another group’s device | AuthServiceTest.aDeviceMayRemoveItselfButNotAnotherGroupsDevice | AuthRoutesTest.aDeviceCannotRemoveADeviceOfAnotherGroup | |
| A member names any active device of its group, not another group’s or a removed one; a sealed name is 1 to 512 bytes; signing in records when a device was last seen, and the list returns both (D-80) | AuthServiceTest.aMemberNamesAnyActiveDeviceOfItsGroupOnly, signingInRecordsWhenTheDeviceWasLastSeen | aDeviceKeepsItsSealedNameAndWhenItWasLastSeen | AuthRoutesTest.aMemberNamesADeviceAndTheListShowsTheNameAndWhenItWasLastSeen; PostgresDatabaseTest.theRootKeyRotationMigrationKeepsRowsFromBeforeIt (a device from before V8 has no name) |
| No group-data endpoint accepts an unauthenticated request; a token for another group is refused | AuthRoutesTest.noEndpointAcceptsAnUnauthenticatedRequestForGroupData, SyncRoutesTest.recordRoutesNeedAValidTokenForTheirGroup | ||
| A device is stored with every field; its id is used once across groups; a group lists its own devices oldest first | aDeviceIsStoredWithEveryField, aDeviceIdIsUsedOnceAcrossGroups, aGroupListsItsOwnDevicesOldestFirst | ||
| The first device proves its key when a group is created; ids are used once | GroupCreationTest.theFirstDeviceMustSignWithTheKeyItRegisters, aGroupIdLookupIdOrDeviceIdIsUsedOnce | AuthRoutesTest.aGroupNeedsItsFirstDeviceToProveItsKey | |
| A QR enrollment: start, member checks the keys, wrap and approve, new device collects once and signs in | EnrollmentServiceTest.aMemberAddsANewDeviceThatCollectsTheRootKeyOnce | anEnrollmentIsCreatedOnceAndFound, approvingAddsTheDeviceOnce | AuthRoutesTest.aNewDeviceJoinsByQrCodeAndReadsTheRootKey (real DeviceEnrollment wrap) |
| An enrollment expires after 10 minutes | EnrollmentServiceTest.anEnrollmentExpiresAfterTenMinutes | AuthRoutesTest.anEnrollmentExpiresAfterTenMinutes | |
| The approval must be signed by the approving member for the scanned keys, once | EnrollmentServiceTest.theApprovalMustBeSignedByTheApprovingMember, anEnrollmentIsApprovedOnce | approvingWithATakenDeviceIdChangesNothing | AuthRoutesTest.theApprovalMustComeFromAMemberAndNameTheScannedKeys |
| Taken ids and malformed keys are refused | EnrollmentServiceTest.takenIdsAndMalformedKeysAreRefused, aDeviceIdTakenBeforeApprovalIsReported | AuthRoutesTest.anEnrollmentCannotTakeAnExistingDeviceIdOrMalformedKeys | |
| The recovery key adds a device and returns the root key copy, which unwraps with the typed words | RecoveryServiceTest.theRecoveryKeyAddsADeviceAndReturnsTheRootKeyCopy | AuthRoutesTest.theRecoveryKeyAddsADeviceAndUnwrapsTheRootKey (real RecoveryKey) | |
| Recovery refuses another key, a reused challenge and an unknown group; works while lapsed, not once expired | RecoveryServiceTest (4 more tests) | AuthRoutesTest.recoveryRefusesAnotherKeyAndAnUnknownGroup | |
| The server’s statements are the same bytes as the clients’ | AuthRoutesTest.theStatementsOfTheServerAndTheClientsAreTheSameBytes |
Rate limits and maintenance (D-69)
| Behavior | Service | Stores | Routes (RateLimitRoutesTest) |
|---|---|---|---|
| A key may burst up to the capacity, then gets 429 with how long to wait; calls come back evenly | RateLimitTest.aKeyMayBurstUpToTheCapacityThenIsLimited, callsComeBackEvenlyOverTheRefillTime, retryAfterIsAtLeastOneWholeSecond | theCallAfterTheCapacityGets429WithRetryAfterAndACode | |
| Keys, limits and policies are counted separately | RateLimitTest.keysAndLimitsAreIndependent, policiesCountPerPolicyAndKey | theTokenEndpointSharesTheAuthBucketAndOtherEndpointsKeepTheirOwn | |
| Of many threads at once exactly the capacity get through | RateLimitTest.ofManyThreadsAtOnceExactlyTheCapacityGetThrough | ||
| Idle buckets are dropped and busy ones kept; a full table refuses new keys until room is made | RateLimitTest.idleBucketsAreDroppedAndBusyOnesKept, aFullTableRefusesNewKeysUntilIdleOnesAreDropped | ||
| A limit must allow a call and refill over time | RateLimitTest.aLimitMustAllowACallAndRefillOverTime | ||
| Enrollments, recovery (per address and per group) and group creation are limited | enrollmentsMayBeStartedTenTimesAnHour, recoveryIsLimitedPerAddressAndPerGroup, theGroupCreationRouteIsLimitedToo | ||
Without a trusted proxy X-Forwarded-For is ignored; behind one each client address has its bucket and only the last entry counts | withoutATrustedProxyTheForwardedHeaderIsIgnored, behindATrustedProxyEachClientAddressHasItsOwnBucket, aCallerCannotChooseItsBucketByPrependingToTheForwardedHeader | ||
| Signed-in sync traffic is not limited | theSyncTrafficOfSignedInDevicesIsNotLimited | ||
| Expired challenges, tokens and enrollments are removed (an expiry at or before now counts), live ones kept; an approved uncollected enrollment goes too; devices stay | MaintenanceTest (4 tests) | AuthStoreContract.deletingExpiredRemovesOnlyWhatHasExpired, deletingExpiredAlsoRemovesAnApprovedEnrollmentWithItsWrappedKey | |
| The job runs while the server runs, only when an interval is given, and its setting is validated | theMaintenanceJobFreesExpiredRowsWhileTheServerRuns, noMaintenanceJobRunsUnlessAnIntervalIsGiven, theMaintenanceIntervalComesFromTheEnvironmentValue |
Root key rotation (D-64, D-74, D-75)
| Behavior | Service (RootKeyServiceTest) | Stores (GroupStoreContract, AuthStoreContract) | Routes (RootKeyRoutesTest) |
|---|---|---|---|
| A new group is at root key 1 with nothing to do; the recovery key’s public key is reported | aNewGroupIsAtRootKeyOneWithNothingToDo | aCreatedGroupIsFoundByIdAndByRecoveryLookupId | |
| Removing a device makes a rotation due until one happens; a later removal makes another due | removingADeviceMakesARotationNeededUntilOneHappens | aRemovedDevicesGroupRotatesItsRootKeyAndEveryoneCanOpenTheNewOne | |
| A rotation moves the id on, replaces the recovery copy and leaves the recovery lookup id, keys and recovery public key alone | aRotationMovesTheIdOnAndReplacesTheRecoveryCopy | rotatingTheRootKeyMovesTheIdOnAndReplacesTheRecoveryCopy | |
| Only the next id can be rotated to; of two rotations at once one wins | onlyTheNextIdCanBeRotatedToOrSent | aRotationToAnyIdButTheNextChangesNothing, twoRotationsToTheSameIdAtOnceHaveOneWinner | onlyTheNextRootKeyIdCanBeRotatedToOrSent |
| A rotation needs an entitled group and a copy of sensible size | aRotationNeedsAnEntitledGroupAndASensibleCopy, aLapsedGroupCannotHandOutKeys | aLapsedGroupCannotRotate, onlyTheNextRootKeyIdCanBeRotatedToOrSent (400) | |
| A device not given the new key is listed as behind until a member seals it; the device collects its copy with its own key | aDeviceNotGivenTheNewKeyIsListedAsBehindUntilAMemberSendsIt, theCallerIsNeverListedAsBehind | aRootKeySealedToADeviceIsKeptUntilReplacedAndRaisesItsId, recordingThatADeviceHoldsTheKeyKeepsNothingForIt | aRemovedDevicesGroupRotatesItsRootKeyAndEveryoneCanOpenTheNewOne (real keys) |
| A copy for an older key is not handed out as the current one; the wrong id, an unknown or removed device and another group’s device are refused | aCopyForAnOlderKeyIsNotHandedOutAsTheCurrentOne, aCopyCannotBeStoredForTheWrongKeyDeviceOrGroup | noRootKeyIsKeptForARemovedOrUnknownDevice | onlyTheNextRootKeyIdCanBeRotatedToOrSent |
| A device enrolled with an older key during a rotation is behind; one with no encryption key (from before D-74) is never listed | anEnrollmentApprovedWithAnOlderKeyLeavesTheDeviceBehind, aDeviceWithoutAnEncryptionKeyCannotBeSentOne | aDeviceEnrolledWithTheOldKeyDuringARotationIsListedUntilSent | |
| The words open the server’s copy of the new key, and a recovered device is given the current one | theRecoveryKeyAddsADeviceAndReturnsTheRootKeyCopy (RecoveryServiceTest) | aRemovedDevicesGroupRotatesItsRootKeyAndEveryoneCanOpenTheNewOne | |
| The three routes need a session for their group | rootKeyRoutesNeedASessionForTheGroup | ||
| A group needs 32-byte device and recovery encryption keys; the first device’s are stored and it holds root key 1 | aGroupCreatedWithoutValidKeysIsRefused | aDeviceIsStoredWithEveryField | aGroupCannotBeCreatedWithoutKeysOfTheRightSize |
| A QR approval stores the scanned encryption key and the root key id; the signature must cover that key | EnrollmentServiceTest.theApprovalMustBeSignedByTheApprovingMember | AuthRoutesTest.theApprovalMustComeFromAMemberAndNameTheScannedKeys, aNewDeviceJoinsByQrCodeAndReadsTheRootKey | |
| Rows from before the migration keep working: devices hold their group’s current key, with no encryption key | PostgresDatabaseTest.theRootKeyRotationMigrationKeepsRowsFromBeforeIt |
Wire format (D-63, :syncApi)
| Behavior | Test (SyncApiTest: JVM, iOS simulator, Android emulator) |
|---|---|
| Fields go on the wire as integer labels (exact bytes) | fieldsGoOnTheWireAsIntegerLabels |
| Envelopes are byte strings and absent fields are left out (exact bytes) | envelopesAreByteStringsAndAbsentFieldsAreLeftOut |
| A request survives the round trip | aRequestSurvivesTheRoundTrip |
| The statements devices sign match known answers, differ by purpose, cover the encryption key and reject a bad id | SyncStatementsTest (7 tests) |
| A delete has no envelope | aDeleteHasNoEnvelope |
| A reader ignores fields it does not know | aReaderIgnoresFieldsItDoesNotKnow |
| The root key classes round-trip; fields added later default when an older writer leaves them out | rootKeyClassesRoundTrip, fieldsAddedLaterDefaultWhenAnOlderWriterLeavesThemOut |
The server suite of the Tests workflow (run by hand) runs these tests, then starts server/compose.dev.yaml with a development build of the server in a container and checks /health and that a sync route answers, which covers the Dockerfile and that compose file. It has not run on GitHub yet.
Gaps
- The promo tool’s
main(connecting withDATABASE_*, quiet logging) and thebin/promostart script have no automated test; they were run by hand against a scratch database on 2026-10-10 (create, list, revoke, a usage error). - Rotation with the server under load, and a removal racing an enrollment: the single winner of two rotations is tested at the store and by the id check in the service; a device approved while another removes the approver is not.
- Not built yet, so not tested: the reverse QR flow, the Apple, Google and Microsoft verifiers and store notifications (renewal, expiry, refund), and rebinding a transaction to another group.
- Deleting an expired group in memory removes only the group and its entitlements: the in-memory stores are separate, so the cascade to records and devices is tested on Postgres only (the contract test checks the group and entitlements).
- That changes become visible in seq order (a reader never sees seq 5 before seq 4 commits) rests on the counter row lock in
PostgresRecordStore; the concurrency tests check unique seqs and one winner, not the order a concurrent reader sees. SyncStatementsTest(7 tests) has run on the JVM, the iOS simulator and an API 28 emulator (2026-10-11) (SyncApiTesthas, on API 28 and 37). It uses Kotlin’sUuid, which is pure Kotlin.- Rate limits across containers are per container (D-69); no test runs two server instances.
- A failing maintenance run (Postgres down) is logged and retried at the next tick; no test makes it fail.
- Live check: a throwaway Python client (hand-made CBOR, its own Ed25519 keys) ran group creation, sign-in, records, QR enrollment, device removal and recovery against
:server:runon Postgres on 2026-10-10, and a burst of 34 calls got 30 answers then 429 withRetry-After: 2; with the interval set to 2 seconds the job deleted a planted expired row. Another on 2026-10-10 (D-70) created a group with a development purchase against an ordinaryinstallDist(422unsupported-source, no warning logged) and a-Pdevelopmentone (201, a one-yeardeventitlement, the start-up warning). The clients are not kept in the repository. Since epic 6 the shared JVM tests reach the auth, record, group-creation and enrollment routes over HTTP throughKtorDestinationandHttpSyncGroup(sync-engine.md). server/compose.test.yamlhas no automated check; it differs from the dev file only in its secrets, restarts and published ports.- A server that cannot reach Postgres at start stops (the pool fails fast); no test covers it.
testRoottests the template greeting, not product behavior.
Storage, migration, hashes and ordering
The SQLite database behind NotesCore, schema migration, content hashes, sort keys and sample data. Decisions: D-09, D-27.
| Behavior | Shared |
|---|---|
| Data survives closing and reopening the database | NotesCoreTest.dataSurvivesRestart |
| A version 1 database migrates and keeps typed titles | OrganizingTest.versionOneDatabasesMigrateAndKeepTypedTitles |
| SHA-256 matches known test vectors | DomainSupportTest.sha256KnownVectors |
| The content hash covers all state that a restore must bring back | DomainSupportTest.contentHashCoversRecoverableState |
| Sort keys always fit between two neighbors and stay short | DomainSupportTest.sortKeysOrder |
| Sample notes seed the same data every time | NotesCoreTest.sampleNotesSeedTheSameDataEveryTime |
| A write against an outdated revision fails as stale | NotesCoreTest.taskToggleCreatesRevisionAndRejectsStaleState, NotesCoreTest.moveDeleteUndelete |
The platform databases (Android SQLite, iOS native driver) are exercised by the integration tests in export-import.md and by every UI test, but always in memory.
Gaps
- Migrations are tested only from version 1. Add a test for each new schema version.
- The on-disk database on a device is never tested; UI tests use an in-memory database. Upgrading an installed app is checked by hand (see
docs/pending.md). - Two writes at once (for example autosave racing a task toggle) are untested.
- iOS backup exclusion (D-11) and Android backup rules (D-10) are untested.
Sync encryption (crypto module)
The end-to-end encryption for cloud sync in crypto/, on libsodium: per-note keys wrapped under the root key, record envelopes, the QR enrollment wrap of the root key, the devices’ X25519 encryption keys, the recovery key, Ed25519 signing keys and the platform key stores. Design in sync-system-design.md. Decisions: D-27, D-45, D-47, D-51, D-55, D-64, D-66, D-74, D-75.
Tests are in crypto/src/commonTest and run on JVM, iOS simulator and an Android device or emulator (connectedAndroidDeviceTest; libsodium has no Android host build). Known answers come from an independent Python script: HChaCha20 checked against draft-irtf-cfrg-xchacha, ChaCha20-Poly1305, X25519 and Ed25519 from the cryptography package, BLAKE2b from hashlib, HKDF from hmac (checked against RFC 5869). Recovery words use the BIP-39 reference vectors (trezor/python-mnemonic) and signatures RFC 8032. The platform key stores have their own tests: crypto/src/androidDeviceTest on a device or emulator, and crypto/src/iosTest on a booted simulator (the test binary is linked with Keychain entitlements; see crypto/build.gradle.kts).
| Behavior | Shared |
|---|---|
| A wrapped note key matches a known answer, both ways | NoteKeysTest.wrapMatchesKnownAnswer, NoteKeysTest.unwrapsKnownAnswer |
| A wrapped note key doesn’t open for another note, a changed byte or another root key | NoteKeysTest.otherNoteIsRejected, NoteKeysTest.everyFlippedByteIsRejected, NoteKeysTest.otherRootKeyWithSameIdIsRejected |
| Rotating the root key rewraps note keys and records still open, for every key generation | NoteKeysTest.rotationRewrapsUnderTheNewRootKey, NoteKeysTest.rewrapKeepsEveryKeyGenerationReadable |
| After a note key rotation, new records use the new key and old ones keep the old; a device holding only the old key can’t read the new records | NoteKeysTest.rotatedNoteKeySealsNewRecordsAndOldKeysStillOpenOldOnes |
| Keys must be 32 bytes | NoteKeysTest.keysMustBe32Bytes |
| A record envelope matches a known answer; a known Unicode envelope opens | RecordCipherTest.sealMatchesKnownAnswer, RecordCipherTest.opensKnownAnswerWithUnicode |
| Sealing round-trips, and two seals of the same text differ | RecordCipherTest.roundTripsWithRandomNonce, RecordCipherTest.emptyPlaintextRoundTrips |
| Any changed byte, record id, context or note key is rejected; so are truncated envelopes | RecordCipherTest.everyFlippedByteIsRejected, RecordCipherTest.otherRecordIdContextOrKeyIsRejected, RecordCipherTest.truncatedEnvelopesAreRejected |
| An envelope names its note key; an unknown key id or the old version 1 is rejected | RecordCipherTest.envelopeNamesItsNoteKey, RecordCipherTest.oldVersionIsRejected |
| The enrollment public key and wrap match known answers, both ways | DeviceEnrollmentTest.publicKeyMatchesKnownAnswer, DeviceEnrollmentTest.wrapMatchesKnownAnswer, DeviceEnrollmentTest.unwrapsKnownAnswer |
| A new device receives the root key and reads existing notes | DeviceEnrollmentTest.newDeviceReadsExistingNotes |
| Another device, another group or any changed byte cannot unwrap | DeviceEnrollmentTest.otherDeviceOrGroupCannotUnwrap, DeviceEnrollmentTest.everyFlippedByteIsRejected |
| Wrong sizes and a low-order public key are rejected | DeviceEnrollmentTest.wrongSizesAndLowOrderKeysAreRejected |
| A sealed device name matches a known answer, both ways; it names its root key (D-80) | DeviceNamesTest.sealMatchesKnownAnswer, DeviceNamesTest.opensKnownAnswer |
| A name moved to another device or group, a changed byte or a root key the device never had does not open; a long name is trimmed and cut to 256 bytes | DeviceNamesTest.aNameMovedToAnotherDeviceOrGroupDoesNotOpen, DeviceNamesTest.everyFlippedByteIsRejected, DeviceNamesTest.aRootKeyTheDeviceNeverHadCannotOpenIt, DeviceNamesTest.aLongNameIsTrimmedAndCutToFit |
Device encryption key (D-74)
| Behavior | Shared |
|---|---|
| A key opens a root key sealed to its public key | DeviceEncryptionKeyTest.aKeyOpensWhatIsSealedToItsPublicKey |
| Only the 32-byte seed is stored; the key is rebuilt from it | DeviceEncryptionKeyTest.theSeedIsAllThatIsStored, DeviceEncryptionKeyTest.aSeedOfTheWrongSizeIsRefused |
| It opens only for its own group and only with its own key; a value that is not a public key is refused | DeviceEncryptionKeyTest.itOpensOnlyForItsOwnGroupAndOnlyWithItsOwnKey, DeviceEncryptionKeyTest.aKeyThatIsNotAPublicKeyIsRefused |
Recovery key (D-55, D-66, D-75)
| Behavior | Shared |
|---|---|
| HKDF-SHA-256 matches RFC 5869 test case 3 | RecoveryKeyTest.hkdfMatchesRfc5869TestCase3 |
| 24 words match the BIP-39 reference vectors, both ways | RecoveryKeyTest.wordsMatchTheBip39Vectors, RecoveryKeyTest.theBip39VectorsReadBackToTheirEntropy |
| The lookup id, recovery signing key and recovery encryption key (seed and public key) match known answers | RecoveryKeyTest.derivedValuesMatchKnownAnswers |
| Anyone with the public key can seal a root key for the server to keep, as a member does when it rotates; only the words open it | RecoveryKeyTest.anyoneWithThePublicKeyCanSealARootKeyAndOnlyTheWordsOpenIt |
| A generated key round-trips through its words | RecoveryKeyTest.aGeneratedKeyRoundTripsThroughItsWords |
| Typing ignores case, numbering and punctuation, and accepts four-letter prefixes | RecoveryKeyTest.typingIsForgiving |
| A wrong word count, an unknown word (with its position) and a failed checksum are told apart | RecoveryKeyTest.aWrongWordCountIsReported, RecoveryKeyTest.anUnknownWordIsReportedWithItsPosition, RecoveryKeyTest.aSwappedOrWrongWordFailsTheChecksum |
| The server’s copy opens only with its key, for its group, unchanged | RecoveryKeyTest.theCopyOnlyOpensWithItsKeyForItsGroup |
| The recovery signing key signs a known answer that verifies | RecoveryKeyTest.theRecoverySigningKeyProvesTheKey |
Signing keys and key storage (D-51)
| Behavior | Test |
|---|---|
| Ed25519 matches RFC 8032 tests 1 and 2 | SigningKeyTest.matchesRfc8032Test1, SigningKeyTest.matchesRfc8032Test2 |
| A key is rebuilt from its 32-byte seed | SigningKeyTest.aKeyIsRebuiltFromItsSeed, SigningKeyTest.aSeedIs32Bytes |
| Verify refuses another message, signature, key or wrong sizes, without throwing | SigningKeyTest.verifyRefusesAnythingElse |
| The vault keeps the root key with its id and the signing key’s seed, and clears | KeyVaultTest.keysRoundTripAndClear |
| A rotated root key becomes the newest and the old one is kept under its id; an older key is kept without becoming the newest; keys newer than the server’s can be dropped (D-75) | KeyVaultTest.aRotatedRootKeyBecomesTheNewestAndTheOldOneIsKept, KeyVaultTest.anOlderKeyIsKeptWithoutBecomingTheNewest, KeyVaultTest.keysNewerThanTheServersCanBeDropped |
| The encryption key’s seed is kept, and clear forgets every key including the archived root keys | KeyVaultTest.theEncryptionKeyRoundTripsAndClearForgetsEveryKey |
| Android Keystore: a secret round-trips, is replaced and deleted | AndroidSecretStoreTest.aSecretRoundTripsAndIsReplacedAndDeleted |
Android Keystore: the file is sealed, in noBackupFilesDir, and does not open under another name | AndroidSecretStoreTest.theFileOnDiskIsSealedAndOutOfBackups, AndroidSecretStoreTest.aFileRenamedToAnotherNameDoesNotOpen |
| Android Keystore and iOS Keychain: a vault’s keys survive a new store instance | AndroidSecretStoreTest.aKeyVaultKeepsTheKeysAcrossInstances, IosSecretStoreTest.aKeyVaultKeepsTheKeysAcrossInstances |
| iOS Keychain: a secret round-trips, is replaced and deleted; services are separate | IosSecretStoreTest.aSecretRoundTripsAndIsReplacedAndDeleted, IosSecretStoreTest.servicesAreSeparate |
Gaps
- Not built: the desktop key store (D-51: macOS Keychain, Windows DPAPI, Linux Secret Service, passphrase fallback) and the optional keychain backup of D-57.
- The Keychain item’s accessibility (
AfterFirstUnlockThisDeviceOnly) is not asserted; the simulator does not enforce it.
Sync engine (client)
The client side of cloud sync in app/sharedLogic/src/commonMain/kotlin/apps/siskin/jotry/sync: the outbox triggers, the push and pull of sealed records, merging and the delete-versus-edit decision. Design in sync-system-design.md section 7. Decisions: D-49, D-64, D-65, D-73, D-74, D-75.
Tests are in app/sharedLogic/src/jvmTest/kotlin/apps/siskin/jotry/sync and run on the JVM only: they use real :crypto envelopes, and libsodium has no Android host build. Each test device is a NotesRepository on its own in-memory SQLite (TestDevice), and the devices share one root key, as if enrolled by QR. They sync through a SyncTestServer, and SyncEngineTest and SyncFaultTest run twice:
- against
FakeSyncServer, an in-memory copy of the server’s record rules (D-65); - against the real server (
SyncOnServerTest):InProcessSyncServerstarts the server’s Ktor module in the test’s process on a free port, over the in-memory stores as a development build (D-70), and each device reaches it over HTTP throughKtorDestination. The first device creates the group with a recovery key and every other one is enrolled by QR and unwraps the root key it was sent (HttpSyncGroup), as the apps will.
Either way, calls can fail before the server acts or after it acted with the response lost (FaultPlan).
Turning sync on and joining by QR code from the apps (epic 7) each have a UI test on both platforms, SyncUiTest and SyncUITests, which go as far as the server: they use an in-memory core and an address where nothing listens.
LiveSyncServerTest syncs two devices through a deployed server. It is skipped unless JOTRY_SYNC_SERVER (the server’s root URL) and JOTRY_PROMO_CODE (a code from bin/promo create, D-72) are set, and creates one group per run. It passed against the test server on 2026-10-11.
| Behavior | Shared, JVM |
|---|---|
| Notebooks, notes, pins and history reach another device | SyncEngineTest.notesNotebooksAndHistoryReachAnotherDevice |
| A note in the default notebook lands in the other device’s default notebook, which is never synced itself | SyncEngineTest.notesInTheDefaultNotebookLandInTheOtherDevicesDefault |
| No readable text reaches the server | SyncEngineTest.theServerSeesNoContent |
| Pulled changes are not queued to be sent back | SyncEngineTest.pulledChangesAreNotSentBack |
| Turning sync on queues what was there before; nothing is queued while it is off | SyncEngineTest.turningSyncOnSendsWhatWasThereBefore, SyncEngineTest.syncIsRefusedWhileOff |
| A synced revision is never replaced by a save in the same minute | SyncEngineTest.aSyncedRevisionIsNeverReplaced |
| Moves, trash, restore, purge, notebook rename and archiving reach the other device | SyncEngineTest.movesTrashAndPurgeReachTheOtherDevice |
| A deleted notebook’s notes, including one added offline elsewhere, move to the default notebook | SyncEngineTest.aDeletedNotebooksNotesMoveToTheDefaultNotebook |
| Edits on both devices keep both versions: one as the note, the other as a conflict copy | SyncEngineTest.editsOnBothDevicesKeepBothVersions |
| A delete meeting an edit asks the user, holds the note back until answered, and both answers converge (D-49, D-73); the decision says which side deleted and gives the note’s title | SyncEngineTest.aDeleteMeetingAnEditAsksTheUserOnTheEditingDevice, SyncEngineTest.aDeleteMeetingAnEditAsksTheUserOnTheDeletingDevice |
| Changes arrive over several pages | SyncEngineTest.manyChangesArriveOverSeveralPages |
| Turning sync off forgets the server and keeps the notes | SyncEngineTest.turningSyncOffForgetsTheServerButKeepsNotes |
| A put whose response was lost is not stored twice | SyncFaultTest.aPutWhoseResponseWasLostIsNotStoredTwice |
| A put lost on the way is sent again | SyncFaultTest.aPutLostOnTheWayIsSentAgain |
| A pull that fails part way keeps the pages it applied and resumes after them | SyncFaultTest.aPullThatFailsPartWayResumesAfterTheLastAppliedPage |
| Three devices making random changes through random faults converge, with no unsent changes left (25 seeds; the runs must include faults, decisions and conflict copies) | SyncFaultTest.randomChangesAndFaultsOnThreeDevicesConverge |
| All of the above against the real server, over HTTP | SyncEngineOnServerTest, SyncFaultOnServerTest (in SyncOnServerTest.kt) |
KtorDestination: a put is stored, a stale one is a conflict with the current seq (409) | KtorDestinationTest.aPutIsStoredAndAStaleOneIsAConflict |
KtorDestination: a write that breaks a rule is refused with the server’s code (422) | KtorDestinationTest.aWriteThatBreaksARuleIsRefusedWithItsCode |
KtorDestination: an expired token is renewed by signing in again (401) | KtorDestinationTest.anExpiredTokenIsRenewed |
KtorDestination: a lapsed group may pull but not push (403 read-only), an expired one may not pull (410) | KtorDestinationTest.aLapsedGroupMayPullButNotPush, KtorDestinationTest.anExpiredGroupMayNotPull |
KtorDestination: a removed device is told so (the server’s 404 no-such-device becomes SyncNotAllowedException with reason REMOVED), and an answer that is not the server’s is a SyncServerException | KtorDestinationTest.aRemovedDeviceIsToldSo, KtorDestinationTest.anAnswerThatIsNotTheServersIsAServerError |
KtorDestination: the root key calls map the server’s answers (current id, rotate once to the next id, conflict, no such device, read-only) | KtorDestinationTest.theRootKeyCallsMapTheServersAnswers, KtorDestinationTest.aLapsedGroupMayReadWhereTheRootKeyStandsButNotRotateIt |
NotesCore.sync: notes reach another core through the server, and a pull that changed notes bumps changes (one that brought nothing does not); an edit bumps edits, which the apps sync after, and a pull does not | CloudSyncTest.notesReachAnotherCoreAndItsScreensHearOfThem |
NotesCore.sync: the membership and keys outlive a restart, and what was written before it is sent | CloudSyncTest.theMembershipOutlivesTheProcess |
NotesCore.sync: leaving forgets the group and both keys and keeps the notes; sync is refused while off or when already joined | CloudSyncTest.leavingForgetsTheGroupAndKeysButKeepsNotes, CloudSyncTest.aDeviceCannotJoinTwice |
NotesCore.sync: a removed device is told so, and after turning sync off joins again under a new device id (the server never takes one twice), gets the rotated root key and syncs | CloudSyncTest.aRemovedDeviceIsToldSoAndCanJoinAgainUnderANewId |
NotesCore.sync: a device that moves to another group sends notes it received from the old group’s devices, with keys it makes for them | CloudSyncTest.notesFromAnotherDeviceOfAGroupLeftBehindReachTheNextGroup |
| Turning sync on creates a group with this device as its only member (promo or development purchase, server address with spaces or a trailing slash), sends the notes made before, and the words shown are the recovery key | CloudSyncTest.turningOnCreatesAGroupWithTheShownWordsAsItsRecoveryKey |
A code the server refuses is SyncTurnOnException (CODE_REJECTED) and leaves sync off | CloudSyncTest.aRejectedCodeLeavesSyncOff |
RecoveryKeyDraft asks for three distinct words in order and takes them in any case, with spaces, or as four-letter prefixes; wrong, missing, swapped or three-letter words fail | CloudSyncTest.theDraftAsksForThreeWordsInOrderAndTakesThemLoosely |
| Joining by QR code (D-77), through the real server. A new core shows a code, is not on until a member approves it, then joins, gets the notes and syncs back with its own key | EnrollmentTest.aScannedDeviceJoinsAndGetsTheNotes |
| The code carries the server, both ids and both keys, survives surrounding spaces, and stays under 250 characters | EnrollmentTest.theCodeCarriesTheServerTheIdsAndBothKeys |
Text that is not a JOTRY1: code, or a damaged one, is refused | EnrollmentTest.anythingElseIsNotACode |
A code whose keys differ from the server’s copy of the request is refused (KEYS_DIFFER), as if the server had put in its own | EnrollmentTest.aCodeWhoseKeysDifferFromTheServersIsRefused |
| A code for another server is refused; one approved already cannot be approved again, and the new device collects its approval once | EnrollmentTest.aCodeForAnotherServerOrOneAlreadyUsedIsRefused |
| Apps: joining by QR code, Android and iOS. Join with a QR code waits for a server address, says when the server can’t be reached, offers a new code, and Cancel goes back | SyncUiTest.joiningWithAQrCodeNeedsOnlyTheServerAndSaysWhenItIsUnreachable (Android), SyncUITests.testJoiningWithAQRCodeNeedsOnlyTheServerAndSaysWhenItIsUnreachable (iOS) |
Apps: the build’s server address (jotry.syncServerUrl). Turn on sync starts with it in the server field. iOS tests that type their own address launch with JOTRY_SYNC_SERVER set to empty (a test-only hook, like the export folder) and a separate class checks the default; the Android tests replace the text | SyncUiTest.theBuildsServerAddressIsAlreadyInTheField (Android), SyncDefaultServerUITests.testTheBuildsServerAddressIsAlreadyInTheField (iOS) |
| Apps: turning sync on (epic 7), Android and iOS. Settings → Turn on sync: Continue waits for an address and a code, the 24 words are shown, wrong words are caught before anything is sent, the right ones go to the server, a server that can’t be reached sends the user back to the first step with the same words | SyncUiTest.turningOnShowsTheRecoveryKeyAndChecksThreeWords (Android), SyncUITests.testTurningOnShowsTheRecoveryKeyAndChecksThreeWords (iOS) |
| Root key rotation (D-64, D-74, D-75), three or four devices through the real server. A removed device makes the first remaining device to sync rotate: the group moves to root key 2, every note gets a second note key and every key list opens under root key 2 alone, the other member is sent the key and collects it, records of existing notes are sealed with the new note key (checked in the envelopes on the server) while older ones keep theirs, and the removed device is refused | RootKeyRotationTest.removingADeviceRotatesTheRootKeyAndTheOthersFollow |
A member lists the group’s devices (this one first, then the newest) and removes one as the apps do (CloudSync.devices, CloudSync.removeDevice): it may not remove itself, removing one again finds nothing, every member sees the shorter list, its next sync rotates, the others follow and the removed device is refused | RootKeyRotationTest.aMemberListsTheDevicesAndRemovesOneFromTheApp |
| Devices name themselves with their default, any member renames any device, every member reads the names and when each last synced, the server holds them only sealed, and after a rotation each device seals its own name again under the new key (D-80) | RootKeyRotationTest.devicesNameThemselvesAndTheNamesAreSealedAgainAfterARotation |
| A device offline across two rotations is given the current key and opens key lists written after both | RootKeyRotationTest.aDeviceOfflineAcrossTwoRotationsCatchesUp |
| Two devices rotating on the same information: one wins, the other forgets the key it made and follows | RootKeyRotationTest.twoDevicesRotatingAtOnceHaveOneWinnerAndTheLoserFollows |
| A rotation whose response was lost is finished by the next sync; one lost before the server saw it is made again with a new key | RootKeyRotationTest.aRotationWhoseResponseWasLostIsFinishedByTheNextSync, RootKeyRotationTest.aRotationLostBeforeTheServerSawItIsMadeAgain |
A device with no copy of the new key waits ([SyncWaitingForKeyException]) until a member that has it syncs and sends it | RootKeyRotationTest.aDeviceWaitsForTheNewKeyUntilAMemberThatHasItSyncs |
| Nothing rotates without a removal, and a removal after a rotation needs another | RootKeyRotationTest.noRotationHappensWithoutARemovedDevice, RootKeyRotationTest.aRemovalAfterARotationNeedsAnotherRotation |
| Two devices converge through a deployed server (opt-in) | LiveSyncServerTest.twoDevicesConvergeThroughTheServer |
Not tested: the engine on Android and iOS (it and KtorDestination compile for them, but no test syncs there; the apps’ UI tests stop before the server), and the real server on Postgres (the in-process server uses the in-memory stores, which pass the same store contracts).
Gaps
- Records waiting in
sync_pending(a record whose key or revision has not arrived): the server’s ordering means it never happens in these tests. - Rotation: a removal racing an enrollment, a rotation by a device whose key lists are not all pulled (it rewrites only what it holds and skips lists wrapped under a root key it never had), and the rotation on Android and iOS (the code is shared and compiles for both; the JVM tests run it).
- Two devices of one group that both move to a new group: each sends the notes they share under its own record ids and makes its own key for them.
- How the engine carries on after a group that may not push or pull (
SyncNotAllowedException) or a record the server refuses (PutResult.Refused);KtorDestinationTestonly checks that the destination reports them. - An edit racing a push of the same record (the outbox
versioncheck). - Apps: showing and scanning a QR code (the camera scanners and the drawn codes have no test), turning sync on against a real server from a phone, the status screen (Sync now, last synced, a sync problem, what a removed device is offered), the devices list and Remove (on all three apps; the shared calls are tested), the delete-versus-edit prompt and the “Waiting for you” list (the engine’s side is tested), turning sync off, syncing when the app comes back or a few seconds after an edit, and background sync with its notification (
SyncWorker,BackgroundSync). The status screen has no UI test because UI tests use an in-memory core with no server. - The schema migration from version 3 is checked by
verifySqlDelightMigration, not by a test that migrates a database with notes in it.
Trash, delete and undo
Deleting notes, the trash, restoring, emptying and the undo bar. Decisions: D-21, D-24, D-40.
- A deleted note goes to the trash and comes back
- Shared:
NotesCoreTest.moveDeleteUndelete - Android UI:
HistorySearchTrashUiTest.deletedNoteGoesToTrashAndComesBack - iOS UI:
HistorySearchTrashUITests.DeletedNoteGoesToTrashAndComesBack
- Shared:
- A restored note returns to its notebook
- Shared:
NotesCoreTest.moveDeleteUndelete - Not tested: Android UI, iOS UI
- Shared:
- Saving a trashed note fails as stale
- Shared:
NotesCoreTest.moveDeleteUndelete - Not tested: Android UI, iOS UI
- Shared:
- Deleting from the note offers Undo instead of asking (D-24)
- Android UI:
HistorySearchTrashUiTest.deletingFromTheNoteOffersUndoInsteadOfAsking - iOS UI:
HistorySearchTrashUITests.DeletingFromTheNoteOffersUndoInsteadOfAsking - Not tested: Shared
- Android UI:
- Bulk deletes can be undone
- Shared:
OrganizingTest.bulkMovesAndDeletesCanBeUndone - Not tested: Android UI, iOS UI
- Shared:
- Empty trash deletes forever unless undone (D-40)
- Android UI:
HistorySearchTrashUiTest.emptyTrashDeletesForeverUnlessUndone - iOS UI:
HistorySearchTrashUITests.EmptyTrashDeletesForeverUnlessUndone - Not tested: Shared
- Android UI:
- Purging removes only trashed notes, with their history
- Shared:
NotesCoreTest.purgingRemovesOnlyTrashedNotesWithTheirHistory - Not tested: Android UI, iOS UI
- Shared:
- Selected notes come back from the trash together
- Android UI:
HistorySearchTrashUiTest.selectedNotesComeBackFromTheTrashTogether - iOS UI:
HistorySearchTrashUITests.SelectedNotesComeBackFromTheTrashTogether - Not tested: Shared
- Android UI:
Gaps
- App closing before the Empty trash undo bar expires (D-40: the notes stay in the trash) is untested.
- A newer undo offer replacing the Empty trash bar, which purges the notes at once, is untested.
- Undo after restoring several notes from the trash (puts them back in the trash) is untested.
- A deleted notebook after its undo bar has gone (D-21: it can’t be restored) is untested.
Untested areas
Code and features that no test checks at all. Gaps within a tested feature are listed in that feature’s file.
| Area | Code | Notes |
|---|---|---|
| Localization (D-26) | i18n/*.json, buildSrc/src/main/kotlin/i18n/ | The generator that writes Android and Apple strings has no tests. All UI and screenshot tests run in English (US), so a missing or broken translation goes unnoticed. |
| Platform text styling | Android ui/editor/EditorSpans.kt, ui/RichTextContent.kt; iOS EditorStyle.swift, RichTextView.swift | How spans become fonts, colors, checkboxes and backgrounds has no unit tests. UI tests and screenshots catch some of it. |
| Platform editor bridges | Android ui/editor/EditorController.kt, JotryEditText.kt; iOS EditorController.swift, EditorTextView.swift | Exercised only through UI tests that type text and tap bold or checklists. |
| Generated titles on devices (D-28, D-30) | Android ui/editor/TitleGenerator.kt, iOS TitleGenerator.swift | Needs the on-device model; no test and no fake. |
| App view models | Android ui/AppViewModel.kt, iOS AppModel.swift | Undo offers, purging and onExpire are tested only through the UI. |
| Folder-sync and extension contracts | sync/SyncContracts.kt, extensions/ExtensionContracts.kt | Interfaces only; nothing implements them yet. The cloud sync engine beside them is tested (sync-engine.md). |
| Server beyond the health check | server/ | /health, the start-up migration, groups and entitlements, records (conditional put and change feed), the sync routes, device auth (challenge, opaque tokens, removal), QR enrollment and recovery are real and tested (server.md). Rate limits on the open endpoints and the cleanup job are tested too. Store verification and the reverse QR flow aren’t built. The sync engine calls the auth and record routes through KtorDestination in the shared tests (sync-engine.md); the apps don’t yet. ApplicationTest.testRoot is the template placeholder. |
| Shared UI and web apps | app/sharedUI, app/webApp, core/ | Template code; SharedUICommonTest.example is a placeholder. The desktop spike’s tests are in desktop.md. |
| App icon generator | tools/generate-app-icons.swift | Run by hand. |
| CI workflow | .github/workflows/tests.yml | Runs only when started manually. |