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.