Building Greenhouse: the vault on disk
The last stretch of Greenhouse work was all audits - reading code someone (me, an earlier session) had already written and finding the places where it quietly disagreed with itself. Useful, but it’s not building. This one’s building. The first feature where I made an empty folder turn into something.
Greenhouse keeps your creative projects in a plain folder on disk. The SQLite database is the source of truth for state - what’s cooling, what’s mature, what you can work on today - but the folder is the thing that matters: it’s portable, you own it, and it’s supposed to outlive the app. If Greenhouse disappears tomorrow, you still have your projects in sensibly-named directories. So the folder layout isn’t a detail. It’s the part that survives.
The shape
Here’s the layout, from the PRD:
/CreativeVault
/.greenhouse/ state.db + config.yaml
/00-ideas/
/10-active/
/20-explore/
...
/60-released/
/90-vault/
The numeric prefixes are the whole trick. Open this in Finder, sort by name, and the folders line up in pipeline order - ideas first, released near the end, vault last. No app required to make sense of it. That’s the “outlives the app” promise made concrete.
Stages versus zones
First real decision: where do those folder names come from? The middle ones - 10-active through 60-released - are pipeline stages, and stages already live in config (each one carries a folder_prefix). So those come from the config, looped over.
But 00-ideas and 90-vault aren’t stages. Ideas haven’t entered the pipeline; the vault is where things go to rest. They’re zones, not steps. Putting them in the stage list would’ve been tidy-looking and wrong - they don’t have plant names, they don’t progress, they bracket the pipeline rather than belonging to it. So they’re constants in the layout module, and the stage dirs come from config:
std::fs::create_dir_all(ideas_dir(root))?;
for stage in &config.stages {
std::fs::create_dir_all(stage_dir(root, stage))?;
}
std::fs::create_dir_all(vault_dir(root))?;
The decision that took the most thought: don’t destroy anything
The function that builds all this, init_vault, runs at first launch. But “first launch” is a lie you tell yourself - it’ll get called again. App restarts, the user re-opens an existing vault, some future onboarding flow re-runs. So the question isn’t “create the vault,” it’s “create the vault, possibly on top of a vault that already exists, without wrecking it.”
Two rules fell out of that:
Directories use create_dir_all, which shrugs if the folder is already there. Re-running is free.
And config.yaml is written only if it’s missing:
let config_path = greenhouse_dir(root).join(CONFIG_FILE);
if !config_path.exists() {
let yaml = serde_yaml::to_string(config)?;
std::fs::write(&config_path, yaml)?;
}
That if !exists is the most important line in the file. Config is hidden from the user in v1, but the folder is portable and inspectable - someone will eventually open config.yaml and change a number. If init blindly wrote defaults every launch, it would silently erase that edit, and the user would never know why their setting kept reverting. The test I’m proudest of writes a config with cooldown_days: 99, re-runs init, and asserts the 99 is still there. A feature defined by what it refuses to overwrite.
Two things I deliberately didn’t do: I didn’t create state.db here (the database module already makes it on open, and both just call create_dir_all on .greenhouse, which is safe to do twice), and I didn’t hardcode where the vault lives. Every entry point - init, database open, config load - takes the root path as an argument. The PRD says /CreativeVault, but that’s an example, not a constant. Which means the vault can live anywhere, and a user can drag the whole folder somewhere else and it still works, because everything inside is relative. The only missing piece is the app remembering where they put it - and that’s app-layer state that can’t live inside the vault it points to, so it’s a problem for another issue.
A file that’s a printout, not a document
Next piece: every project folder gets a project.md - a plain-English summary of the project that you can read in any text editor, no app required. Title, what stage it’s in, when you last touched it, the log of handoff notes you left yourself. It’s the human-readable face of what the database knows.
The key word is mirror. This file is not where your project lives - the database is the source of truth. project.md is a reflection of it, regenerated whenever something changes. Which immediately raises the question the PRD itself flags as unresolved: what happens when the user edits the mirror? Two answers. One-way - regenerate, overwrite, the file is a printout and your edits are lost. Or reconciliation - read changes back, merge them into the database, handle conflicts.
For v1 I went one-way, hard. The function that writes it does exactly one thing:
std::fs::write(project_dir.join(PROJECT_MD), contents)?;
No read, no merge, no diff. Regenerate and replace. Reconciliation sounds friendlier but it’s a trap this early: you’d be inventing a two-way sync protocol and conflict UI for a file most users will never touch, to protect edits the file’s own header should tell them not to make. The honest version of “human-readable mirror” is “this is a printout of the database - edit the project in the app.” One-way is the feature, not a shortcut around it.
The rendering itself is a pure function - item plus its touch history plus config in, a string out. No clock, no disk:
pub fn render_project_md(item: &Item, touches: &[Touch], config: &Config) -> String
That purity means I can test the exact output without writing a single file, and the write function is a trivial two-liner wrapped around it. It also forced a small clarification: the issue asked for “notes history,” and I went looking for a notes field. There isn’t one. The notes are the handoff notes - the little “here’s where I left off” messages you attach when you stop working. So the history section is just the touch log, rendered newest-first, each line a date and the note you left (or a quiet “no note” for the times you didn’t). The data was already there; “notes history” was just a second name for it.
What I didn’t do, again, was decide where the file goes. write_project_md takes the target directory as an argument. Because here’s the thing I kept bumping into: there’s no concept yet of where a project’s folder is. Promoting an idea to a project sets a status flag and… that’s it. No folder gets made. The function that’s supposed to do it even has an unused parameter sitting there - a stage id it accepts and ignores. So the mirror knows how to render itself and how to write itself to a folder, but “which folder” is a question the codebase can’t answer yet. I wrote that gap up as its own issue rather than guess at it here. The mirror’s ready for the day projects actually have homes.
Meeting people where they already are
A tool that demands you start from scratch is a tool most people close. Anyone who’d want Greenhouse already has a folder of half-finished projects somewhere. So the last piece of the folder work was adopt/import: point the app at an existing folder, and it pulls the subfolders in as projects - no fresh start required.
The engine half is two functions. Scan, which lists the subfolders worth offering:
for entry in std::fs::read_dir(dir)? {
if !entry.file_type()?.is_dir() { continue; } // dirs only
if name.starts_with('.') { continue; } // skip .git, .greenhouse
candidates.push(ImportCandidate { name, path });
}
And import, which turns a chosen folder plus a stage into a project record and seeds its project.md mirror. Both small. The interesting parts were two decisions.
First: how do you not import the same folder twice? Someone will scan, import half the folders, and scan again next week. So a project needs to remember which folder it came from, and import has to refuse a folder that’s already mapped. That meant finally adding a piece I’d been dancing around for two issues - a folder_name stored on each project, the durable link between a database row and a directory on disk. Import was the first feature that genuinely couldn’t work without it, so this is where it landed. Re-scan, try to import a folder that’s already in: rejected, with an error that says exactly that.
Second, and the one I went back and forth on: when you import a folder and say “this is a Build-stage project,” should the app move the folder into the Build directory? The layout says projects live in numbered stage folders, so moving would keep things tidy. But moving is reaching into someone’s filesystem and rearranging it the moment they try the app. That’s a hostile first impression for a tool whose whole pitch is “you own this folder.”
So v1 adopts in place. The folder stays exactly where it is; the database records which stage it’s in. The on-disk layout won’t perfectly match the numbered-folder ideal, and that’s the right trade - non-destructive import beats tidy import when you’re asking someone to trust you with their work for the first time. The reorganization can come later, as something the user opts into, not something import does behind their back.
The parameter that did nothing
I’d been building all these folder pieces - the vault, the mirror, the import - around a hole I kept stepping over. Promoting an idea to a project is the moment a project is born. And the function that did it, promote_idea, looked like this:
pub fn promote_idea(db, item_id, _first_stage_id: &str, now) -> Result<()> {
db.update_item_status(item_id, &ItemStatus::Active)?;
// record a touch, start the cooldown
}
Look at _first_stage_id. The underscore is Rust telling you, out loud, “this argument is accepted and ignored.” The caller passes in which stage the project should start at, and the function throws it away. It flips a status flag and nothing else. No stage gets set, no folder gets made. A “promoted” project was Active in name only - homeless, stageless, with no project.md to its name. Every folder feature I’d built had been writing checks this function couldn’t cash.
So this was the issue that connected the wiring. Promotion now does what its name promises:
let folder_name = unique_folder_name(db, &item.name)?; // stable slug
let project_dir = vault::stage_dir(root, first_stage).join(&folder_name);
std::fs::create_dir_all(&project_dir)?;
db.update_item_stage(item_id, &first_stage.id)?;
db.update_item_folder_name(item_id, &folder_name)?;
mirror::write_project_md(&project_dir, &updated, &touches, config)?;
Every primitive from the earlier sections clicks into place here - stage_dir from the vault work, write_project_md from the mirror, folder_name from import. The plumbing was all laid; this just opened the valve.
One detail I’m glad I thought about: the folder name is a separate thing from the project’s display name. You title a project “Untitled Sketch,” and it gets the folder untitled-sketch. Later you rename it to “The Real Thing.” Should the folder move? No. The folder name is set once, at birth, and never changes - renaming the project updates the title in the database and in project.md, but the directory on disk stays put. Tie a folder’s identity to a name people are free to change and you’ve signed up for a lifetime of moving directories and dodging collisions every time someone edits a text field. The folder gets a stable name and keeps it.
The other half is the mirror image: when a project legitimately does change stage - finished exploring, on to building - the folder follows it, sliding from 20-explore to 40-build with a plain filesystem move, and project.md regenerates to reflect the new stage. Rename: folder stays. Stage change: folder moves. Two kinds of change, two opposite answers, and getting them backwards would be a slow-motion mess.
That’s the back end done. An idea can now travel the whole road - captured, left to mature, promoted into a real folder on disk, worked on and cooled down, advanced through stages, and eventually vaulted - with the database holding the truth and the folder holding the work. Nothing you can click yet. But underneath, Greenhouse finally does the thing it’s for. The next chapter is teaching it to show its face.
Related reading
The empty database that looked perfectly healthy
SQLite treats a zero-byte file as a valid fresh database, so every corruption check passed - and the backup pruning would have deleted the good copies within a week.
Building Greenhouse: the rules engine that came before the app existed
Writing a full Rust rules engine and 22 tests before Greenhouse had a UI, or even a working Rust toolchain on the machine building it - and what broke the moment it actually compiled.
The status that nothing ever wrote (Greenhouse engine dev log)
A status no code set, a parser that guessed, timers that disagreed on 'after', and the long tail of an engine review: sorts, constraints, half-written decisions, and a settings file that could brick the app.