Jonathan Mellette Contact

Solutions architecture · Pre-1.0 (v0.4.0), paused · AGPL-licensed, source not yet public

OpenSteward

A zero-server envelope budgeter: each device appends to its own log in a synced folder, and a Hybrid Logical Clock makes every machine fold those logs into the same books.

Status
Paused: no code changes since 29 Jul 2026. The last packaged desktop build is v0.1.1 (build 4, 25 Jul 2026).
Version
0.4.0 in source, the seventh untagged version in five days (24–28 Jul 2026); never packaged
Licence
AGPL-3.0-or-later, in a private repository
Runs on
Web build in Chromium browsers; desktop builds packaged through a Tauri shell for macOS (Apple Silicon) and Windows, last at v0.1.1
Stack
TypeScript · React 19 · Vite · Tauri 2 (Rust filesystem commands) · File System Access API · append-only JSONL logs + Hybrid Logical Clock
Links
None yet: the source is not public

Measured · how

By hand

Assigning every bank-export row to a budget category.

Now

Suggestions from the register’s own history, each held until a person confirms it: in Fig. 1, 3 of 6 imported rows arrive pre-assigned.

At a glance

lines in the sync engine; the Hybrid Logical Clock is 78 of them
133
shuffled merge orders, one identical state: 11 events, 3 simulated devices, colliding clocks
500
runtime npm packages, transitive ones included (the desktop shell adds 432 Rust crates)
6
native file commands, none of which can overwrite, truncate or delete
4
How these were measured
  • 133: wc -l over the four engine files at HEAD (the clock 78, the event type 19, the fold 16, the log 20), 12 Oct 2026.
  • 500: the project’s own test command, node --test "src/**/*.test.ts", run 12 Oct 2026. 9 of 9 tests passed, including the seeded 500-shuffle convergence test. The working tree was clean before and after.
  • 6: entries in package-lock.json not marked dev-only (65 are dev-only, 44 of them optional per-platform binaries). 432: grep -c '^name = ' over the desktop shell’s Cargo.lock gives 433, one of which is the app’s own crate; the count spans every target platform.
  • 4: the commands registered in the Tauri handler (list, read, append, folder-readable). The append opens files in create-and-append mode, and the capability file grants the web layer only the core defaults and the folder picker.
  • Versions: seven values of the package.json version field in git history, 0.1.0 to 0.4.0, 24–28 Jul 2026; git tag is empty. The desktop shell’s Cargo.lock still records 0.1.1.
OpenSteward’s register in import review, on invented demo data. A banner reads 6 imported transactions to confirm. Three imported rows already carry a budget category; three show an empty budget-item slot.
Fig. 1Three rows pre-assigned from history, three held for a person: six made-up bank rows imported into the invented demo register. Confirm stays disabled until a row has a budget item. Captured 12 Oct 2026 from a fresh build of the current source. Full size

The short version

  1. Convergence without a server or agreed clocks. Each device appends only to its own file, and every machine folds the union in Hybrid Logical Clock order. In the engine’s convergence test, 500 shuffled merge orders of a scripted three-device log fold to identical state.
  2. Built rather than adopted, for zero-server sync and a web-layer dependency tree you can read in one sitting: 6 runtime npm packages, transitive ones included.
  3. Automation suggests and a person confirms: every imported row waits for approval, the same human gate AI-assisted workflows need.

Case 1 of 5

Convergence without a server, a lock, or agreeing what time it is

Every device writes only its own log, and a Hybrid Logical Clock gives every event one total order, so every machine computes the same books.

Multi-device sync usually means a server that orders the writes. OpenSteward has none. Each device appends only to its own log file in a folder the person picks, so a file-sync service only ever copies whole files and never merges one. The budget on screen is a pure fold of the union of every log.

Each event carries a Hybrid Logical Clock stamp: physical milliseconds that never move backward, a counter for collisions, and the device id as the final tie-breaker. Packed at fixed width, the stamp sorts correctly as a plain string and doubles as the event id. Reading another device’s log moves the local clock past every stamp seen, so an edit made after reading a colleague’s change sorts after it, whatever the wall clocks say. Sync waits for the device’s own queued writes before reading, money is integer cents, and the reducer still reads older event shapes, because an append-only log keeps them for good.

The HLC is a published technique; the source credits the idea to Actual Budget’s CRDT layer and says it is the only idea taken from it. The implementation, and the serverless design around it, is the part of this I’d defend line by line.

How every device reaches the same books Three devices each append to their own log. A synced folder holds one append-only file per device. The app takes the union of every file, sorts it by Hybrid Logical Clock stamp (milliseconds, counter, device id), and folds it with a pure reducer, so the same events in the same order give the same books on every machine. Device A own log Device B own log Device C own log Synced folder one append-only file per device Union, sorted by HLC stamp millis · counter · device id Pure fold same events, same order, same books
Fig. 2The sync service never merges anything; the merge is a sort and a fold inside the app.
How we know

The clock and the fold are src/engine/hlc.ts and src/engine/fold.ts. The convergence test in src/engine/engine.test.ts scripts 11 events across three simulated devices whose wall clocks collide, including two cross-device reads. It folds them in 500 seeded shuffles and checks each result against the canonical fold, plus 100 shuffles that check the last-write-wins winner. It uses a toy two-event reducer, not the budget reducer. Run 12 Oct 2026 with the project’s test command: 9 of 9 passed. Also exercised 12 Oct 2026 in a fresh build in headless Chromium, with the browser’s private file system standing in for a chosen folder: a simulated second device’s log was dropped into the folder, Sync reported “2 new”, and the working balance moved from $82,119.00 to $82,227.00, exactly +$108.00 ($150 in, $42 out). Nothing was written to the project.

Case 2 of 5

Build vs adopt: a web-layer dependency tree you can read in one sitting

Adopting a mature open-source budgeter was the default. Building won on zero servers and a web-layer dependency set small enough to vet end to end.

The default was to adopt Actual Budget, a mature, local-first envelope budgeter. Two requirements overruled it. Sync had to work with zero servers, through plain files in a folder the person already syncs, and Actual’s multi-device sync runs through a sync server. And the dependency set had to be small enough to vet end to end, where a fork brings a large transitive tree.

The same requirements shaped the rest. A mutable database file in a synced folder can be grabbed mid-write; per-device append-only files have one writer each. The CSV reader is written in-house, because every dependency must be licence-checked against the AGPL. The money-field calculator is a small shunting-yard evaluator with no eval(). And the desktop shell is Tauri, not Electron, for a smaller attack surface.

How we know

Package counts from package-lock.json and src-tauri/Cargo.lock, 12 Oct 2026. The CSV reader is src/domain/csv.ts; the calculator is src/domain/calc.ts, whose 3 tests are among the 9 that passed.

Case 3 of 5

The desktop build that looked perfect and couldn’t open a folder

Packaged naively, the macOS app would launch, look right and never open a real budget; two storage backends and four small Rust commands fixed it.

The web build reaches the budget folder through Chromium’s File System Access API. Packaged with Tauri, the macOS app renders in WKWebView, Safari’s engine, which has no folder picker, so a naive package quietly falls back to browser storage. The fix is two storage backends behind one interface: a directory handle in the browser, a plain path on the desktop, where all file access goes through four small Rust commands.

Shipping is scripted. The Mac build refuses a dirty tree, because it bundles the working tree rather than HEAD. It bumps the build number, stamps it into the app, then re-signs and verifies the signature before installing. That check arrived after build 1 came out with a signature broken by stamping after signing. The Windows build reuses the number without bumping it, so one commit yields two binaries with one build number.

How we know

Backends in src/app/fs.ts and src/app/fs-tauri.ts; commands in src-tauri/src/lib.rs; permissions in src-tauri/capabilities/default.json; build scripts in scripts/. Git history shows four packaged builds on 25 Jul 2026, between 15:00 and 16:27, with the re-sign-and-verify change committed after build 1. Checked 12 Oct 2026: the installed macOS app is 10,552 KB (arm64 only, build 4, ad-hoc signature), and the Windows portable .exe is 9,541,632 bytes (v0.1.1, no Authenticode signature).

Case 4 of 5

Automation that suggests, and a human who confirms

Import suggests categories from the register’s own history, and nothing it guesses counts until a person confirms it.

A bank export names the merchant, not the budget category, and card exports change the store number on every visit. Import uses the file’s own category column when it names an existing category. Otherwise it tries the exact payee from history, then an exact stored default, then a normalised name (processor prefixes, store numbers and reference codes stripped) against each. The most recent use wins, and an exact match beats a normalised one.

No guess lands in the books unseen. Every imported row is stored as needing review and arrives uncleared. A row can’t be confirmed without a budget item, Confirm all stays disabled until every row has one, and because the flag lives in the event log, closing the app halfway approves nothing. In Fig. 1 the gate holds one new merchant and two payee variants the normaliser missed. Import never removes duplicates: an export carries no transaction id, so two identical purchases can’t be told apart, and the dialog warns that rows already imported won’t be detected.

That is the shape AI-assisted workflows need: deterministic suggestions behind a required human gate.

How we know

Normalisation in src/domain/payee.ts; resolution order and the confirm gate in src/components/RegisterScreen.tsx; the duplicate warning in src/components/ImportDialog.tsx. Fig. 1 was captured 12 Oct 2026 by importing a made-up six-row file into the invented demo register.

Case 5 of 5

Verification that can actually fail

An importer can only prove it agrees with itself, so the reference check is an independent oracle that exits non-zero on any difference.

The strategy is to copy YNAB 4’s budgeting behaviour exactly first, on purpose, and diverge later, so that every divergence is chosen against a baseline that has been shown to match. That parity isn’t finished yet. The oracle that checks it is separate from the importer. It computes a live reference budget from first principles, out of the reference app’s own files, folds the app’s live folder through the same selectors the screen uses, compares every envelope, balance and month header, and exits non-zero on any difference. It runs against a private budget, wasn’t passing at its last recorded run, and can’t be re-run for this page.

Beneath it sits an invariant that must hold whatever the reference says: available-to-budget plus every envelope balance equals the working balance. On the invented demo budget it holds.

envelope balances, end of July
$76,569.00
plus available to budget
+ $5,550.00
equals the working balance: cleared $79,145.00 plus uncleared $2,974.00
= $82,119.00
Fig. 3The cash identity on the invented demo budget, re-derived 12 Oct 2026.
How we know

The oracle is scripts/compare-live.mjs (198 lines; it exits 0 only when every compared figure matches). It was not run for this page, because it reads private financial data. The cash identity comes from a scratch copy of the project’s demo generator, run 12 Oct 2026 (265 events, 27 categories, 112 transactions); nothing was written to the project.

Invented demo data throughout, from the project’s own generator