Construyendo Greenhouse: el motor de reglas que llegó antes que la app
Greenhouse es un gestor de proyectos creativos, pero no del tipo que solo guarda tareas. Impone un proceso: capturar ideas todos los días, rotar entre proyectos en vez de hiperenfocarse en uno solo, cumplir un enfriamiento obligatorio de 7 días después de cada sesión de trabajo, no borrar nunca nada. El método está adaptado del sistema de proceso creativo de Mike Monday en Make Music Your Life, generalizado más allá de la música hacia un motor neutral de dominio.
El primer día fue el PRD y la creación del repositorio. El primer día también fue GRNH-1: el motor de reglas. Todavía no existía interfaz. Ni pantallas, ni ventanas, nada que hacer clic. Solo Rust.
Ese orden fue deliberado. El PRD que escribí esa misma mañana describe el motor como “una calculadora de estado, no un guardián”: algo que responde “¿cuál es mi situación ahora mismo?” y muestra avisos, pero nunca bloquea una acción por sí solo. Si la lógica de enfriamiento, los temporizadores de maduración y las reglas del vault viven detrás de una interfaz desde el inicio, es demasiado fácil dejar que la interfaz se convierta silenciosamente en la fuente de verdad en vez de las reglas. Construir el motor primero, sin interfaz en la que apoyarse, obligó a que cada regla fuera explícita y comprobable de forma aislada. En el issue de Plane que escribí para esto, hasta lo marqué prioridad #1 “según el interesado”, que era yo mismo, pero escribirlo así mantuvo honesto el orden.
El commit que lo consolidó es un scaffold completo de Tauri v2 envolviendo un núcleo puro en Rust:
config.rs: structsStageConfigyConfig, con los valores por defecto de la plantilla “plant” ya incluidos: 7 días de enfriamiento, 7 días de maduración de idea, 14 días de maduración de vault, cargados desdeconfig.yaml.state.rs: un enumItemStatus, un tipoItemque refleja la fila de la base de datos, yderive_item_state: una función pura que toma un ítem y una marca de tiempo y devuelve su estado derivado. Sin efectos secundarios, sin leer el reloj dentro de la función; quien la llama pasa el “ahora” como parámetro.db.rs: el esquema de SQLite, dos tablas (itemsytouches), operaciones CRUD vía el build empaquetado derusqlite, así que no hay dependencia del SQLite del sistema que gestionar.touch.rs:record_touch, la única función que inicia un enfriamiento, másvault_itemypromote_idea.daily.rs:derive_daily_state, que junta todo en unDailyState: la lista de trabajo del día, qué ideas están por vencer, qué necesita una decisión de revisión de vault, si se capturó algo hoy, y una advertencia de inventario bajo si el flujo se está quedando corto.
Veintidós pruebas unitarias en esos cinco módulos, cada una anclada a una marca de tiempo fija en vez de llamar al reloj del sistema. La lógica de enfriamiento y maduración es exactamente el tipo de código que parece correcto y luego falla de forma intermitente seis meses después porque una prueba corrió cerca de un límite de día. Fijar el “ahora” como parámetro de entrada, y no como un global ambiental, evita eso desde la primera prueba.
Acá está la parte que no esperaba tener que contar: el mensaje del commit termina con “Requires rustup to build/test - Rust not yet installed on dev machine” (requiere rustup para compilar/probar, Rust todavía no está instalado en la máquina de desarrollo). Escribí un motor de reglas completo, cinco módulos, veintidós pruebas, sin poder compilar una sola línea. Todo a partir de leer el PRD y pensar las transiciones de estado en papel.
El siguiente commit, 23 minutos después, se titula “fix(engine): all 22 tests passing” (arreglo: las 22 pruebas pasan), lo cual dice bastante de cómo salió. Una vez instalado Rust de verdad, dos cosas se rompieron de inmediato. Primero, el código de arranque de Tauri no compilaba para cargo test sin que existieran íconos de la app en disco, así que la librería misma fallaba al compilar hasta que agregué íconos RGBA de relleno. Segundo, los helpers de prueba de la base de datos abrían un archivo SQLite real dentro de un TempDir y dejaban que el directorio se eliminara mientras la conexión todavía lo usaba, una condición de carrera de tiempo de vida que no falla igual dos veces garantizado. Cambiar todas las pruebas a una base de datos en memoria lo arregló de forma definitiva en vez de parchar una sola corrida intermitente. Ninguno de los dos problemas apareció en el diseño en papel. Los dos aparecieron solo cuando Rust real corrió las pruebas.
Lo que sigue: el shell del asistente de incorporación, la primera pantalla que alguien va a ver de verdad. El motor lleva ahí desde el primer día, esperando que algo lo llame.
Lecturas relacionadas
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.
Configurando Claude Code para un proyecto de iOS
Escribiendo el CLAUDE.md de una nueva app de iOS antes de que exista una sola línea de Swift: la trampa de MusicKit en el simulador, las reglas de CloudKit para SwiftData, y por qué el enfoque de proyecto de aprendizaje vino primero.