Jodd case study · BBMedia

Founder-led · hands-on · AI-directed

Strategy came back to the keyboard—and became working evidence.

Jodd began as Kaiwan Hongladaromp's way to get close to product building again after years in senior technology leadership. A real cross-device notes problem became a public test of whether experience, hands-on engineering, and carefully directed AI could ship together.

01 · Starting point

A project playground with a real reason to exist

Kaiwan's career spans more than three decades across internet infrastructure, telecommunications, payments, and regulated technology. After years working at leadership and architecture altitude—most recently as chief technology officer of a licensed payment provider—he wanted to prove something to himself before claiming it to anyone else: that he could return to day-to-day product building and use modern AI tools with judgment rather than novelty.

The problem was personal and concrete. His notes lived comfortably on a Mac, but more work was moving across Windows and Android. A small note-sharing experiment grew into a technically demanding product playground: local-first state, Gmail-backed Apple Notes round-trip, multi-device UX, OAuth, sync conflict handling, and safe agent access.

The goal was not to invent a startup story first. It was to build enough truth that the story could be inspected afterwards.

02 · What shipped

Claims tied to things you can open

Jodd is still a Developer Preview. Its value as a showcase comes from making the working parts and the unfinished parts equally visible.

01

Multi-platform delivery

Versioned builds for Apple Silicon macOS, Windows, and a signed universal Android APK, produced by a repeatable release workflow.

Open release v0.24.1 →

02

Local-first architecture

SQLite is the immediate source of truth for the interface; background workers reconcile Gmail or Local Folder storage without putting backend latency in normal editing.

Read the architecture →

03

Bounded agent access

An optional MCP server can search notes and perform deny-by-default, folder-allowlisted writes. Stale or ambiguous task edits are refused instead of guessed.

Inspect the MCP boundary →

04

Traceable trust work

Permissions, local caching, desktop signing limitations, feature gaps, release provenance, and dependency alerts are stated and checked rather than hidden behind launch copy.

Read what changed →

03 · How it is built

The interface never waits for a network.

Every edit is written to a local database and reflected on screen in the same synchronous step. A background worker reconciles with Gmail, Microsoft Graph, or a local folder afterwards. The user is never on the far side of that boundary.

The local-first write path Two horizontal flows separated by a dashed stepped boundary. Above the boundary, four stages run one after another inside a single keystroke: the keystroke arrives in NoteEditor.svelte, the notes.ts store updates the screen optimistically, apply_local_edit writes the row to SQLite, and control returns to the user with the row marked dirty. Below the boundary, a second flow runs later on a five-second tick: the sync worker drains dirty rows through list_dirty, dispatches them through a boxed Vertical to Gmail, Microsoft Graph, or local disk, and mark_pushed settles the row back to clean. A curved arrow crosses the boundary from the return stage down to the sync worker, showing the handoff is one-way and nothing upstream waits for it. latency boundary — the user never crosses this Keystroke NoteEditor.svelte Optimistic DOM notes.ts store SQLite write apply_local_edit Returns sync_state = dirty synchronous · one transaction · no network in the path picked up later — nobody is waiting Sync worker 5s tick Drain list_dirty Vertical Box<dyn Vertical> Backend Gmail · Graph local disk Settle mark_pushed → clean

The write path. Everything above the dashed line completes before the keystroke returns; everything below it happens on a five-second tick the user never waits for.

How this actually works

A keystroke updates the Svelte store and calls apply_local_edit, which writes the row and sets sync_state = 'dirty' in one SQLite transaction. Control returns immediately. On the next tick the sync worker calls list_dirty, pushes through the account's vertical, then mark_pushed returns the row to clean.

An AppState.pushing set holds (account_id, uuid) pairs for in-flight pushes, so the next poll does not mistake Jodd's own write for a remote-side change and manufacture a conflict against itself.

The rule this enforces, stated in the project's own contributor documentation: any normal navigation or editing command that blocks on the remote is a bug, and any frontend state mutation that happens after an awaited IPC call is a bug.

One cache, one conflict model, three transports

Gmail, Microsoft Graph, and a plain local folder differ in authentication, in how a note is identified, and in what saving one even means. They share everything above the transport seam.

The vertical abstraction Three stacked bands. The top band is the shared core, holding the SQLite cache in db.rs, the sync worker, the conflict reconciler, and the AppleHtmlDeriver. An arrow points down from it into the middle band, a dashed box for the boxed Vertical trait object and the traits it is composed of. Three equal columns sit below, each with an arrow pointing up into that trait band: GmailVertical over Gmail REST, MicrosoftVertical over Microsoft Graph on Exchange, and LocalFsVertical over the filesystem. All three columns disagree on transport, and all three save differently. The remaining two disagreements group the columns differently again, and crosswise to each other. Authentication pairs Gmail with Exchange, both on OAuth 2.0 with PKCE, against a local folder that authenticates nothing. Identity pairs Gmail with the local folder instead: both are keyed on Apple's own X-Universally-Unique-Identifier header, carried inside the stored message, while the Exchange column has no Apple header to key on at all and falls back to internetMessageId. Every one of those disagreements stops at the trait band, so nothing above it is written three times. Shared core SQLite cache (db.rs) Sync worker Conflict reconciler AppleHtmlDeriver Box<dyn Vertical> Transport · NoteStore · Identity · Deriver · MetadataSidecar · Capabilities GmailVertical Gmail REST insert-new + trash-old OAuth 2.0 + PKCE keyed on X-Universally-Unique-Identifier MicrosoftVertical Graph (Exchange) PATCH in place OAuth 2.0 + PKCE keyed on internetMessageId LocalFsVertical filesystem write + remove old no auth keyed on X-UUID in the file folders: write-disabled, permanently no network at all

The vertical abstraction. Three backends resolve to one Box<dyn Vertical>, and no single line divides them. All three save differently; identity pairs Gmail with a local folder, both keyed on Apple's X-Universally-Unique-Identifier, which Exchange lacks; authentication pairs Gmail with Exchange, both OAuth 2.0 with PKCE, against a local folder with none.

How this actually works

vertical_for(account) dispatches on the account's backend kind. The sync worker, the conflict reconciler, and every command are written once against the trait rather than once per backend.

The differences are real, not cosmetic. Gmail has no replace operation, so a save is insert-new plus trash-old and the cached message id must be repaired afterwards. Microsoft Graph PATCHes in place, so a folder change needs a separate explicit move call. A local folder has no account to authenticate — readiness is "the directory exists."

Identity splits two against one. Gmail notes and local-folder notes are both keyed on Apple's X-Universally-Unique-Identifier header — a local folder stores each note as an .eml file carrying that same header — while an Exchange note carries none of Apple's headers at all and is keyed on internetMessageId. The RFC 822 builder — build_note_mime — splits the same way: shared by those same two, deliberately unused by the third. The module it lives in does not split so cleanly: the Exchange vertical still borrows one format-neutral helper from it, format_apple_uuid, to mint a note id.

Notice that the two splits cut crosswise. Identity pairs Gmail with the local folder against Exchange; authentication pairs Gmail with Exchange against the local folder. No ordering of these three backends puts the odd one out in the same place twice.

04 · How the data is handled

Two devices editing one note is a policy question, not a race.

When the same note is edited in two places, neither version is discarded. A conflict copy is created with the local content and the primary row converges to the remote. The user resolves it by editing; the software never picks a winner on their behalf.

Note sync states and the keep-both branch Three solid boxes across the upper left carry the sync states a note actually reaches. Clean and dirty sit side by side, joined by a pair of arrows: clean becomes dirty when the user edits, through apply_local_edit, and dirty returns to clean once the worker pushes it, through mark_pushed. A third box, deleted_pending, hangs below clean, reached by mark_deleted when the user deletes; the arrow is drawn from clean because that is the ordinary case, but mark_deleted updates the row whatever state it was in, so a dirty note lands there too. A thicker arrow drops from dirty into a dashed box labelled keep-both branch, annotated reconcile_one and "dirty plus remote version changed"; a note beside that arrow records that AppState.pushing skips the whole check while Jodd's own push is still in flight, so the app cannot manufacture a conflict against itself. Two thick arrows fan out of the branch into two solid result boxes side by side. The left one, primary row, converges to the remote through upsert_from_remote and ends clean. The right one, conflict copy, gets a fresh UUID, keeps the local content, has "(conflict from …)" appended to its title, and is inserted through insert_local_new as dirty, so the worker will push it too. Underneath both, a note reads that neither version is discarded. Off to the right, inside a dashed frame of its own, two further boxes named pull_needed and conflict are drawn dashed rather than solid, with one arrow between them labelled apply_local_edit; a note under the frame explains that both are declared in the SyncState enum and reserved, but no code path writes either state today, so the keep-both branch runs without them. clean local matches remote dirty local edit pending deleted_pending delete pending push user edits apply_local_edit pushed mark_pushed user deletes mark_deleted AppState.pushing skips this while our own push is in flight keep-both branch reconcile_one dirty + remote version changed Primary row converges to the remote upsert_from_remote → clean Conflict copy fresh UUID · local content kept title + “(conflict from …)” insert_local_new → dirty neither version is discarded pull_needed conflict apply_local_edit declared in SyncState and reserved — no code path writes either state today

Note sync states and the keep-both branch. A conflict produces a second row with a fresh identity rather than overwriting either side — and it does so without passing through a conflict state: pull_needed and conflict are declared in the SyncState enum and reserved, but no path writes either one today.

How this actually works

reconcile_one takes the keep-both branch when a note is locally dirty and its remote version token changed since the last sync. It creates a copy under a fresh UUID, titled with a (conflict from {Device} {Date}) suffix, preserving the local content, and lets the primary row converge to the remote version. The copy is inserted dirty, so the worker pushes it too — both versions end up on the account, and the user resolves by editing one and deleting the other.

"Version token" rather than "message id" is deliberate. Each backend supplies its own opaque change token — Gmail's is the message id, Microsoft's is lastModifiedDateTime, a local folder's is the Date header — and the comparison is written against the token, not against any one backend's idea of identity.

Two of the five declared note states are, as of this writing, unreached. SyncState declares clean · dirty · pull_needed · conflict · deleted_pending, but nothing in the codebase writes pull_needed, and every route into conflict is closed behind it. The three conditional writers all take the same pull_needed → conflict branch — the edge inside the dashed frame above — and wait on a state that never arrives: two sit in the shared body behind apply_local_edit, and the third in move_notes_batch, which its own doc comment describes as transitioning through the same state machine. The one unconditional writer is a helper with no callers. So the keep-both branch runs entirely between dirty and clean. Both states are left declared rather than deleted because the transitions out of them are already written, though written is as far as it goes: no test writes either one. The diagram draws them dashed and apart rather than pretending they are live.

Folders carry a smaller state set — clean · dirty_new · dirty_renamed · deleted_pending — all four of which are reached. Their hierarchy is auto-completed on both the locally-initiated and the pulled-from-backend path, so a backend that permits a leaf label without its parent never produces an orphaned row.

Where the data actually sits

The local cache is encrypted at rest with AES-256 via SQLCipher, and the key lives in the operating system's own credential store — the same place the OAuth refresh token already lives.

Trust boundaries around a note Four columns divided by three vertical dashed lines. The first and widest column, the user's device, stacks four boxes: the SQLite cache, encrypted with AES-256 through SQLCipher; the OS credential store, holding the cipher key alongside the OAuth refresh token; process memory, which holds the short-lived access tokens and the label cache and stores no refresh token; and accounts.json, which is plaintext metadata. The second column, network, holds TLS and OAuth 2.0 with PKCE, with a note that an intercepted authorization code is useless without the per-flow verifier. The third column is the user's own account — Gmail at gmail.googleapis.com, or Microsoft Graph at graph.microsoft.com. The fourth column is the user's Apple devices, an iPhone and a Mac, both running Notes.app against that same account. Along the bottom, a pair of arrows crosses every dashed line, one pointing right and one pointing left, because the same note travels in both directions. Three notes close the picture: a Local Folder vault is plaintext on purpose; no BBMedia server appears anywhere among the four columns, which is the design rather than an omission; and the single exception, Android sign-in, bounces only the OAuth code through a static page and carries no note data. The user's device Network The user's own account Apple devices SQLite cache AES-256 · SQLCipher OS credential store cipher key + refresh token Process memory access tokens · label cache accounts.json plaintext metadata TLS OAuth 2.0 + PKCE an intercepted code is useless without the verifier Gmail gmail.googleapis.com Microsoft Graph graph.microsoft.com iPhone Notes.app Mac Notes.app A Local Folder vault is plaintext, deliberately — see below. No BBMedia server appears in this diagram. That is the design. Android sign-in alone bounces the OAuth code through a static page — no note data.

Trust boundaries. There is no BBMedia server in this diagram, and its absence is the design — notes sync between the user's device and the user's own Gmail or Microsoft account, and nowhere else. The one BBMedia-hosted thing in the product touches no note: a static page that bounces the OAuth authorization code back into the app on Android, where the browser will not hand the callback over directly.

How this actually works

The cipher key is a 256-bit random value used with SQLCipher's raw-key syntax. It is deliberately not derived from a passphrase: the key is machine-generated and never human-typed, so there is no guessable input for a key-derivation function to slow down.

A failed keychain read is never treated as "no key yet." load_key_hex returns Ok(None) only when the entry genuinely does not exist; a locked credential store returns an error. Collapsing the two would let a fresh key be written over a good one and leave the already-encrypted database permanently undecryptable.

A wrong key is caught at open rather than later. PRAGMA key never errors on a wrong key — only the first real read does — so open_encrypted forces a canary query before returning.

The exception, stated plainly: a Local Folder vault is not encrypted, on purpose. Encryption covers the cache of a remote source of truth — one file, jodd.sqlite3. A directly-readable folder of .eml files on a disk the user chose is the entire point of that backend, and encrypting it would remove the property it exists to provide.

The other exception, also stated plainly: Jodd reaches a BBMedia-hosted URL on its own initiative exactly once, and only during Android sign-in. Google will not hand an OAuth callback to an app mid-navigation, so the redirect lands on a static page that re-issues the authorization code as an Android intent. It carries no note content and runs no server-side logic. Two other BBMedia URLs are in the source — the Privacy and Terms buttons in the About panel — but those hand the address to the system browser on an explicit click, so the app itself never requests them. There is no telemetry, no analytics, and no crash reporter anywhere in the codebase, so the only hosts the sync path ever contacts are Google's and Microsoft's own API endpoints. Outside the sync path, the optional LLM features reach whichever provider the user configures — a decision that is entirely theirs, and unset by default.

Read the full trust-boundary documentation →

05 · AI operating model

AI expands execution capacity. It does not remove accountability.

Jodd was developed with AI copilots and task-specific agents, but the operating system around them matters more than the model names.

  1. 1

    Frame

    Kaiwan defines the user problem, product boundary, risk tolerance, and what evidence would count as done.

  2. 2

    Delegate

    A coordinating copilot decomposes work into bounded tasks and delegates research, implementation, testing, or review when useful.

  3. 3

    Surface decisions

    Agents return progress, evidence, conflicts, and trade-offs. Material choices stop at a checkpoint instead of disappearing into generated code.

  4. 4

    Own the result

    Kaiwan reviews the boundary, decides what ships, merges deliberately, and remains responsible for the public claim and release.

The operating loop and what each step leaves behind Four stages run left to right along the top — Frame, Delegate, Surface decisions, Own the result — and beneath each hangs the artifact it leaves behind. Frame leaves 24 design specs and 27 implementation plans. Delegate leaves bounded agent tasks. Surface decisions leaves review findings. Own the result leaves a chain of four, read downwards: 878 tests, of which 680 are Rust and 198 Vitest, then 3 CI workflows, then a gate, and only past that gate the 38 tagged releases. Three diamond-shaped gates sit on the path. Two are human. The first stands between Frame and Delegate: nothing is delegated until the spec is approved. The second stands between Surface decisions and Own the result: nothing is owned until the findings are triaged, and an arrow labelled rejected leaves that gate and runs back along the top of the diagram to Delegate, so work that fails triage goes round again instead of forward. The third gate is the machine gate in that downward chain, and it is one of the three CI workflows above it: the Android encryption proof, which runs on a real emulator. It is drawn between the workflows and the tagged releases because that is exactly what it blocks — publication rather than merge — so a failure there leaves every binary built and nothing published. rejected Frame Delegate Surface decisions Own the result human gate spec approved human gate findings triaged 24 design specs 27 implementation plans bounded agent tasks review findings 878 tests 680 Rust · 198 Vitest 3 CI workflows machine gate the Android encryption proof runs on a real emulator and blocks publication, not merge a failure there leaves every binary built and nothing published 38 tagged releases

The same loop, with what each step leaves behind. Two of the three gates are human, and rejected work returns to Delegate rather than moving forward. The third is machine-run — one of those three CI workflows is the Android encryption proof, which executes on a real emulator — and it stands between the workflows and the tagged releases because what it blocks is publication, not merge: a failure there leaves every binary built and nothing published. Figures measured 2026-08-16 — how these numbers were measured.

A decision, worked through

A four-step model is only worth describing if it changes what ships. This is one decision it produced, end to end. The part that settles it is not the attempt that failed — it is the control underneath, which is what separates the platform will not do this from we did not manage it.

The question
Can Jodd create folders in Apple Notes for a Microsoft account, the way it already can for Gmail?
What was tried
Both folder-creation surfaces Microsoft Graph exposes — POST /me/mailFolders and POST /me/mailFolders/{id}/childFolders — and then the one route those two leave open: create at mailbox root with the class requested, then POST /me/mailFolders/{id}/move it under Notes. All three against a live account. It is the third that turns two failures into a closed set.
What was found
Neither can set the container class Apple's Notes sync requires, and the class cannot be changed afterwards — patching it returns 500 ErrorObjectTypeChanged. Not sync lag: the folders were still absent from Notes.app 21 hours later and after a forced resync, and reading them back showed the class had silently come out as IPF.Note even where IPF.StickyNote was requested at creation. No sequence of Graph calls produces a folder Apple will display.
The control that ruled out a Jodd bug
Folders created by hand in Notes.app sync immediately, and notes written through Graph into one of those hand-made folders reach Apple without issue. The variable is who created the folder — not whether Jodd can write notes into it. That is what makes this a limit of the platform rather than a defect to fix.
What shipped
The capability is set to false, permanently, in Capabilities::for_backend. The folder-writing code exists and is unit-tested; the capability gate keeps it switched off and the interface stops offering it. The evidence sits in the doc comment beside the flag.
Why this is the part worth showing
The interesting output was not a feature. It was a proven limit, recorded as a capability the running software honours in two independent places — the interface refuses to offer the action, and the sync worker drops any folder write that reaches it anyway. So it stops being rediscovered, and stops being shipped as something that fails quietly on a user's phone.

A second decision — four constraints in the Android OAuth redirect chain, three of which are invisible from, or contradicted by, the vendor's own documentation — is written up in Engineering practice.

What this case study does not claim

It does not claim that AI autonomously founded a company, that every generated first draft was correct, or that Jodd is already a consumer-ready business. It demonstrates a practical way for one experienced person to direct more parallel capability while keeping decisions, review, and responsibility human.

Work with BBMedia

Product Prototype & Technical Validation Sprint

A focused engagement for a founder or team with a promising idea, a half-built internal tool, or a technical product decision that has stayed abstract for too long. The aim is not more presentation material. It is enough working evidence to decide what to build next.

Start a scoped conversation

A useful fit

  • An idea needs a credible working demonstration.
  • A prototype exists but the architecture or path to release is unclear.
  • AI could accelerate the work, but the team needs boundaries and accountable decisions.
  • A technical assumption needs evidence before a larger investment.

Typical evidence delivered

  • A bounded prototype or demonstrable solution.
  • Architecture, risk, and decision notes.
  • Test, build, or release evidence appropriate to the scope.
  • A clear recommendation to continue, change direction, or stop.

Scope, duration, and commercial terms are agreed before work begins. A prototype sprint does not replace independent security, legal, compliance, or production-readiness review where those are required.