Building Greenhouse: the rules engine that came before the app existed
Greenhouse is a creative-project manager, but not the kind that just stores tasks. It enforces a process: capture ideas daily, rotate between projects instead of hyperfocusing on one, sit out a mandatory 7-day cooldown after each work session, never delete anything. The method is adapted from Mike Monday’s creative-process system in Make Music Your Life, generalized past music into a domain-neutral engine.
Day one was the PRD and the repo init. Day one was also GRNH-1: the rules engine. No UI existed yet. No screens, no windows, nothing to click. Just Rust.
That ordering was deliberate. The PRD I wrote that same morning describes the engine as “a state calculator, not a gatekeeper”: something that answers “what’s my situation right now?” and surfaces nudges, but never blocks an action outright. If cooldown logic, maturity timers, and vault rules all live behind a UI from the start, it’s too easy to let the interface quietly become the source of truth instead of the rules. Building the engine first, with no UI to lean on, forced every rule to be explicit and testable in isolation. In the Plane issue I wrote for it, I even marked it priority #1 “per stakeholder”, which was just me, but writing it that way kept the sequencing honest.
The commit that landed it is a full Tauri v2 scaffold wrapped around a pure Rust core:
config.rs:StageConfigandConfigstructs, with the “plant” template defaults baked in: 7-day cooldown, 7-day idea maturity, 14-day vault maturity, loaded fromconfig.yaml.state.rs: anItemStatusenum, anItemtype mirroring the DB row, andderive_item_state: a pure function that takes an item and a timestamp and returns its derived state. No side effects, no clock reads inside the function; the caller passes “now” in.db.rs: the SQLite schema, two tables (itemsandtouches), CRUD viarusqlite’s bundled build so there’s no system SQLite dependency to manage.touch.rs:record_touch, the only function that starts a cooldown, plusvault_itemandpromote_idea.daily.rs:derive_daily_state, which rolls everything up into aDailyState: the day’s worklist, which ideas are due, what needs a vault-review decision, whether anything’s been captured today, and a low-inventory warning if the pipeline’s running dry.
Twenty-two unit tests across those five modules, every one of them pinned to a fixed timestamp instead of calling the system clock. Cooldown and maturity logic is exactly the kind of code that looks correct and then flakes six months later because a test ran near a day boundary. Fixing “now” as an input, not an ambient global, sidesteps that from the first test.
Here’s the part I didn’t expect to be writing about: the commit message ends with “Requires rustup to build/test - Rust not yet installed on dev machine.” I wrote an entire rules engine, five modules, twenty-two tests, without being able to compile a single line of it. All from reading the PRD and thinking through the state transitions on paper.
The very next commit, 23 minutes later, is titled “fix(engine): all 22 tests passing”, which tells you how that went. Once Rust was actually installed, two things broke immediately. First, the Tauri bootstrap code wouldn’t compile for cargo test without app icons existing on disk, so the lib itself failed to build until I added placeholder RGBA icons. Second, the test database helpers were opening a real SQLite file inside a TempDir and letting the directory get dropped while the connection was still using it, a lifetime race that isn’t guaranteed to fail the same way twice. Switching every test to an in-memory database fixed it for good instead of papering over one flaky run. Neither problem showed up in the design on paper. Both only showed up once real Rust actually ran the tests.
Next up: the onboarding wizard shell, the first screen anyone will actually see. The engine’s been sitting there since day one, waiting for something to call it.
Related reading
Building blog-manager, part 1: the Blog model and a UI that doesn't look like a scaffold
Starting blog-manager: a Rails Blog model with Active Record Encryption for per-blog credentials, and a CRUD UI I'd actually enjoy using.
A CI failure playbook for a one-person Rails project
Writing down the rules for what to do when CI goes red on a solo Rails project, and the GitHub limitation that turned the merge gate into a load-bearing comment.
Setting up Claude Code for an iOS project
Writing the CLAUDE.md for a new iOS app before a line of Swift exists - the MusicKit-in-simulator trap, CloudKit's SwiftData rules, and why the learning-project framing came first.