Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Open the Apple, Google Play and Microsoft developer accounts (epic 8). They have lead time and block nothing else.
  2. Finish Phase 2 (beta readiness, reviews, manual accessibility QA).
  3. 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.
  4. Run the server suite 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). LiveSyncServerTest passed 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.dockerignore re-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. Confirm docker compose ... up --build still builds the same image with Docker.

  • ScreenshotTest.notebook is 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.titleIsTheFirstLineUntilRenamed is 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.testSettings fails 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 text within 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, the SDK_INT check in TitleGenerator.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/service holds the framework-free logic and server/src is the Ktor adapter (D-60); HealthService and /health are 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) and server/README.md.
  • The build and all 16 server tests pass, and both compose files run for real: the server migrates at start, /health answers 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/postgres module reads DATABASE_*, opens the pool, applies the migrations at start (an empty V1__baseline.sql so far) and gives /health its database probe. Tests run on a real Postgres, a fresh database per test.
  • A server suite in the Tests workflow, run by hand like the others (never on push): the server and :syncApi JVM tests and :server:check, then compose.dev.yaml with a development build of the server in a container, a /health check 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 :crypto on 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 the SyncApi.cbor settings (integer labels, byte strings, unknown keys ignored, definite lengths). The route list is in SyncApi.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: SyncGroup with the recovery fields (D-55), Entitlement, the GroupStore port, and EntitlementPolicy (entitled while any is active, read-only when lapsed, expired a year later; D-52, D-56) in server/service; the in-memory store and the shared GroupStoreContract in its test fixtures; PostgresGroupStore (Exposed DSL) and V2__groups_and_entitlements.sql. When a group lapsed is worked out from its entitlements, not stored. The clock is java.time.Clock.
  • K3: records (D-65). RecordStore port and RecordService (the conditional put on seq, 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) in server/service; the in-memory store and RecordStoreContract in its test fixtures; PostgresRecordStore (Exposed DSL) and V3__records.sql with the group_counter row 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) in server/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 the SYNC_PROTOTYPE switch 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_id is 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 DeviceEncryptionKey per device (D-74), kept in the KeyVault beside 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), behind SecretStore and KeyVault. The Keychain tests pass on a booted simulator; the Keystore tests on API 37 and API 28.
  • Run the :crypto tests on Android API 28 (minSdk) and 37: 48 tests pass on both, as do the 5 :syncApi tests on API 28 (2026-10-10). The API 28 emulator is the Api28_Phone AVD. On a real phone (moto g play 2023, Android 13) all 56 :crypto tests 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 RecoveryKey and DeviceEnrollment from :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_FOR for 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=true on the test server, behind Caddy (2026-10-11). compose.test.yaml passes it through from server/.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 in server/dev, which only a development build contains (:server:run, the tests, installDist -Pdevelopment); :server:check fails if it reaches an ordinary build (D-58, D-70). SYNC_PROTOTYPE is retired. Live-checked 2026-10-10: an ordinary build answers 422 unsupported-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 DELETE on Postgres that cascades to everything the group holds.
  • Add a purchase to an existing group (D-71): POST /v1/groups/{groupId}/purchases through 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 as promo purchases 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. SyncEngine in app/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 until resolve. Tested against FakeSyncServer with 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.
  • KtorDestination in app/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, 403 read-only and 410 mapped to PutResult and SyncNotAllowedException (now with a reason); other answers throw SyncServerException. 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. LiveSyncServerTest passed against the test server at https://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, changes bumped when a sync changed notes, the group id and server URL in sync_state, the root and signing keys in KeyVault (Android Keystore, iOS Keychain; in memory for in-memory cores), and a KtorDestination made on first use. join takes the keys a create, QR enrollment or recovery produced; leave forgets them on this device only. :crypto is now an api dependency of sharedLogic. 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-key and PUT /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: CloudSync settles 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.turnOn creates 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 SyncWorker every 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. SyncDecision now 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.devices and CloudSync.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-device is now SyncNotAllowedException with reason REMOVED, 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_SECURE while 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-review skill); 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 security tool (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 \r and attachment characters), Android (JotryEditText turns Paste into Paste as plain text) and the desktop (the Compose text field’s own paste). The text goes through EditorEngine.replace like 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 to RichText, 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:run and 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 i18n module.
    • 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.db in the data folder; rename it before anyone keeps notes there.

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?
  • 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-xx yet): 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).

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/desktopApp and app/desktopDev builds 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_22F77 and deleting its image bundle under /Library/Developer/CoreSimulator/Cryptex/Images.