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 |