Saltar al contenido
Development

El estado que nada llegó a escribir (bitácora del motor de Greenhouse)

Por Victor Da Luz
rustsqlitetauridev-loggreenhouse

Estoy construyendo Greenhouse, una app de escritorio que impone un ritmo al proceso creativo: capturar ideas, dejarlas reposar, rotar entre proyectos, nunca borrar nada. El motor de reglas está en Rust, y una sesión anterior ya lo había entregado: 22 pruebas pasando, build limpio, issue cerrado.

Así que cuando llegó el siguiente issue, “definir el modelo de estado por ítem”, parecía ya resuelto antes de empezar. El modelo estaba justo ahí en state.rs. Cinco valores de estado, un cooldown derivado, una bandera de madurez. Cada campo que pedía el issue.

Estuve a punto de cerrarlo como duplicado. En cambio, leí el código.

Investigación

El estado del ítem era un enum:

pub enum ItemStatus {
    FreshIdea,
    MatureIdea,
    Active,
    Vaulted,
    Released,
}

MatureIdea es el interesante. Una idea empieza fresca y se vuelve “madura” después de un temporizador, momento en el que hay que decidir: trabajar en ella, o guardarla en la bóveda. Un valor de estado parece razonable.

Excepto que nada lo fijaba jamás.

Hice grep sobre todo el crate. La ruta de promoción iba directo de FreshIdea → Active. Nada transicionaba nunca a MatureIdea. Y el dashboard diario, lo que muestra “ideas que ya necesitan una decisión,” consultaba solo las ideas frescas:

let all_ideas = db.list_items_by_status(&ItemStatus::FreshIdea)?;

Acá está la trampa. Si algún código futuro sí llegaba a fijar un ítem como MatureIdea, y el enum invita a eso porque está ahí, disponible, ese ítem desaparecería. La lista de trabajo activa lo excluye. La consulta de ideas pendientes no lo trae. Estaría en la base de datos, con un estado válido, e invisible en todos lados.

La madurez estaba modelada dos veces: una vez como un booleano derivado calculado a partir de un timestamp, otra vez como un estado guardado. Las dos nunca estaban obligadas a coincidir, así que eventualmente no iban a coincidir.

Después encontré el segundo. La función que lee un estado de vuelta desde SQLite:

fn str_to_status(s: &str) -> ItemStatus {
    match s {
        "fresh_idea" => ItemStatus::FreshIdea,
        // ...
        _ => ItemStatus::FreshIdea,  // anything else
    }
}

Cualquier cadena no reconocida se convierte en una idea fresca. En la mayoría de las apps eso es un valor por defecto perezoso pero inofensivo. En una app sin borrado es un desastre silencioso: una fila Released corrupta o migrada a medias vuelve en silencio a ser una idea fresca y reingresa al pipeline de captura. La única promesa que hace el producto, que nada se pierde ni se altera a espaldas de quien lo usa, deshecha por una rama de “lo que sea”.

El arreglo

Elegir un solo modelo. La madurez es derivada, punto. El motor es una calculadora de estado, no un guardián, responde “qué es cierto ahora mismo” a partir de timestamps. Así que un MatureIdea guardado no tenía razón de existir. Lo quité. Una idea madura ahora es solo una idea fresca cuyo maturity_reached calculado es verdadero.

Y el deserializador deja de adivinar:

fn str_to_status(s: &str) -> rusqlite::Result<ItemStatus> {
    match s {
        "fresh_idea" => Ok(ItemStatus::FreshIdea),
        // ...
        other => Err(/* unknown item status: ... */),
    }
}

Un estado desconocido falla en voz alta. Para un producto sin borrado, fallar en voz alta es el comportamiento correcto.

Posdata: el desfase de un segundo que no era consistente

Con el modelo ya limpio, seguí con los issues de temporizadores, cooldown y madurez, esperando más del mismo trabajo de “ya está construido, solo hay que verificar.” Estaban construidos y probados. Pero al compararlos lado a lado, no coincidían en un detalle mínimo: el límite.

Cooldown preguntaba: ¿esto sigue bloqueado?

let is_cooling = now < until;   // available the instant we hit `until`

Madurez preguntaba: ¿ya pasó suficiente tiempo?

now >= created_at + idea_maturity_secs   // mature the instant we hit the threshold

Uno era estricto, el otro inclusivo. En el segundo exacto en que expira el temporizador, cooldown decía “disponible ahora” mientras que madurez decía “madura ahora”, convenciones opuestas para la misma idea, “pasó una duración desde un evento.” Las dos especificaciones usaban la misma palabra: después. Disponible después del cooldown. Madura después del temporizador.

Así que hice que coincidieran en la lectura estricta. El estado cambia estrictamente después de que transcurre la duración completa; en el tick exacto del límite, se sigue estando en el estado “antes” por un segundo más:

let is_cooling = now <= until;                       // cooling through the boundary
now > created_at + idea_maturity_secs                // mature strictly after

Un segundo en un temporizador de 7 días no cambia nada que un usuario vaya a notar jamás. Pero el trabajo entero de un motor de reglas es el timing predecible, y que dos temporizadores usen matemática de límites distinta es el tipo de cosa que muerde después, alguien escribe “las reglas de cooldown y madurez tienen la misma forma,” y en silencio no la tienen. Ahora sí la tienen. Cada cambio vino con una prueba que fija el límite exacto, para que la siguiente persona no pueda hacerlos divergir por accidente.

Una estadística que tiene que ganarse su lugar

El mecanismo de touch, la acción “trabajé en esto” que arranca un cooldown y opcionalmente toma una nota de traspaso, ya estaba construido. Pero su issue tenía una línea más callada que había pasado por alto: registrar si los proyectos con notas de traspaso se terminan con más frecuencia, y mostrar eso con delicadeza.

Los datos ya estaban ahí. Cada touch guarda su nota. Así que podía comparar: de los proyectos que llegaron a una conclusión, ¿los que tenían notas se terminaban con más frecuencia que los que no?

La versión fácil se escribe sola: contar cuántos terminan en cada grupo, dividir, mostrar el porcentaje. La trampa es mostrarlo demasiado pronto. Con dos proyectos terminados, “¡100% de quienes toman notas terminan!” no es un hallazgo, es ruido disfrazado de dato. Y esta es una herramienta sobre ser honesto con uno mismo; una revelación falsa es peor que ninguna.

Así que la estadística se autolimita:

pub fn has_meaningful_sample(&self) -> bool {
    self.concluded_with_notes >= MIN_SAMPLE_PER_GROUP
        && self.concluded_without_notes >= MIN_SAMPLE_PER_GROUP
}

Y el dashboard solo la muestra cuando se la ganó:

let insight = insight::handoff_insight(db)?;
let handoff_insight = insight.has_meaningful_sample().then_some(insight);

Por debajo del umbral, el campo es None y la interfaz no muestra nada. “Mostrar con delicadeza” resultó significar “casi siempre no mostrar nada.” El trabajo principal de la funcionalidad es saber cuándo quedarse callada.

Una decisión chica más: ¿qué cuenta como un “proyecto que concluyó”? Fui con solo los estados terminales, Released (terminado) o Vaulted (guardado en bóveda). Un proyecto Active todavía no eligió, así que contarlo como “no terminado” arrastraría cada tasa hacia cero y haría que las notas parecieran inútiles. Los resultados solo se comparan una vez que en verdad son resultados.

El ordenamiento que corría al revés

El dashboard diario de Greenhouse muestra una “lista de trabajo activa”, los proyectos que se pueden retomar hoy. Todo el sentido de la app es la rotación: empuja hacia el trabajo que ha estado descuidado y, mediante el cooldown, aleja de lo que se acaba de tocar. Así que la lista de trabajo debería poner el proyecto más descuidado arriba de todo.

El código lo ordenaba así:

ORDER BY last_touched DESC NULLS LAST

Eso es lo trabajado más recientemente primero, lo nunca tocado al final. Exactamente al revés. El proyecto ignorado durante un mes queda abajo del todo; el que acaba de terminar su cooldown queda arriba. El comentario tres líneas más arriba incluso decía “los ítems descuidados suben”, el código hacía lo opuesto a su propia descripción.

Nada lo atrapó porque la única prueba verificaba cuáles ítems estaban en la lista, nunca su orden:

assert_eq!(state.available_worklist.len(), 1);
assert_eq!(state.available_worklist[0].id, "available");

Un ítem adentro, un ítem afuera, el orden es invisible con una muestra de tamaño uno. Los ordenamientos son el lugar clásico donde esto se esconde: la lista se ve bien, está llena de las cosas correctas, y está al revés.

El arreglo es una línea, más una prueba que en verdad fija la secuencia:

ORDER BY last_touched ASC NULLS FIRST
-- → [never-touched, longest-neglected, ..., most-recently-worked]
assert_eq!(order, ["never", "old", "mid", "recent"]);

Si un ordenamiento importa, hay que verificar el orden, no solo el contenido.

El ordenamiento que no ordenaba

Ese ordenamiento al revés tuvo una secuela, y es la misma línea de SQL con un disfraz distinto.

Greenhouse corre una revisión diaria “Rescatar o Mantener”: una vez al día muestra un proyecto en bóveda que lleva suficiente tiempo ahí como para reconsiderarlo. La regla es “mostrar el que lleva más tiempo dormido.” El código tomaba todos los ítems en bóveda y elegía el primero maduro:

let vaulted_items = db.list_items_by_status(&ItemStatus::Vaulted)?;
let vault_review_item = vaulted_items
    .iter()
    .map(|item| derive_item_state(item, config, now))
    .find(|s| s.maturity_reached);

“El primero maduro” solo significa algo si la lista está ordenada. Y list_items_by_status ordena por la misma columna que antes, last_touched. Para los proyectos activos eso tiene sentido. Para los que están en bóveda no es nada: un proyecto en la bóveda no se está tocando, así que su last_touched es NULL y se queda en NULL.

Así que cada fila en bóveda ordena por el mismo valor idéntico, NULL, cae en un mismo bucket NULLS LAST, y SQLite las devuelve en el orden que se le antoja. .find() elige el primer ítem maduro de una pila arbitraria. El ordenamiento al revés al menos ordenaba, este no ordenaba en absoluto. Una clave que es constante entre las filas es lo mismo que no tener clave.

Se veía estable en mi máquina con pocas filas. La misma trampa que la vez anterior, un poco más silenciosa: “arbitrario” no promete “mal en cada corrida,” promete “ninguna promesa.” Agregar una fila o correr sobre un build distinto de SQLite hace que el empujón diario apunte a otro lado en silencio.

El valor que necesitaba ya estaba en el esquema, vaulted_at, el momento en que el ítem entró a la bóveda. El más dormido es simplemente el más chico. Así que los ítems en bóveda consiguieron su propia consulta en vez de pedir prestada la de la lista de trabajo:

ORDER BY vaulted_at ASC NULLS LAST

El más antiguo en bóveda primero; el .find(maturity_reached) encima ahora entrega el ítem maduro más dormido por construcción. La prueba inserta tres ítems maduros fuera de orden más uno demasiado reciente y fija cuál aparece, porque, igual que con el ordenamiento al revés, un bug de orden es invisible hasta que una prueba verifica el orden.

Misma consulta, tercera lección sacada de ella: un ORDER BY sobre una columna que es NULL justo para las filas que se están seleccionando no está ordenado, es “lo que sea que devuelva el motor”, y va a parecer determinista hasta que deje de serlo.

La restricción que no restringía

Una más, y es mi tipo favorito: una baranda de seguridad que parece instalada pero no está atornillada a nada.

El esquema declara que cada touch pertenece a un ítem real:

CREATE TABLE touches (
    id        TEXT PRIMARY KEY,
    item_id   TEXT NOT NULL REFERENCES items(id),
    ...
);

Ese REFERENCES items(id) se lee como una garantía: no se puede registrar un touch para un ítem que no existe. Excepto que SQLite viene con las llaves foráneas apagadas por defecto. La aplicación de la restricción solo se activa si se corre PRAGMA foreign_keys = ON sobre la conexión, y nada lo hacía. Así que la restricción era documentación, no una regla. Se podía insertar un touch apuntando a un ítem fantasma y la base de datos lo aceptaba sin problema.

Todavía no había mordido porque la única ruta de código que escribe touches verifica primero que el ítem exista. Pero esa es una defensa que descansa por completo en que el código de la aplicación se acuerde de tener cuidado, justo lo que la restricción de la base de datos se supone que vuelve innecesario.

El arreglo es una línea, fijada en cada conexión al abrirla:

self.conn.execute_batch("PRAGMA foreign_keys = ON;")?;

Y una prueba que intenta insertar un huérfano y espera un rechazo:

let result = db.insert_touch("t1", "ghost-item", 1000, Some("note"));
assert!(matches!(result, Err(crate::EngineError::Db(_))));

La declaración estaba bien. Lo que faltaba era la aplicación. Escribirlo no es lo mismo que activarlo.

Media decisión

Una revisión del repo encontró algo que ninguno de los hallazgos anteriores había atrapado, porque no falla en ninguna corrida individual, solo falla en una corrida que se detiene a la mitad. La acción “guardar un ítem en la bóveda” de Greenhouse son dos sentencias SQL:

db.update_item_status(item_id, &ItemStatus::Vaulted)?;
db.set_vaulted_at(item_id, now)?;

Cada una hace commit en el instante en que corre, no hay una transacción alrededor del par. Si el proceso muere entre las dos (un crash, la app matada a la fuerza, SQLITE_BUSY por algo más tocando el archivo), la primera sentencia sobrevive y la segunda no. El ítem ahora es Vaulted con vaulted_at = NULL.

Ese NULL no es cosmético. La consulta de revisión de bóveda ordena los ítems en bóveda por vaulted_at para encontrar “el que lleva más tiempo dormido,” el mismo bug de ordenamiento que este arco ya cubrió una vez, salvo que esta vez la columna misma falta, no solo está desordenada. Un NULL ordena al final y nunca se lee como maduro, así que el ítem queda en bóveda para siempre y nunca vuelve a aparecer para Rescatar-o-Mantener. Guardado, en silencio, para siempre. En una app cuya premisa entera es “nada se pierde nunca de verdad,” ese es justo el resultado que el diseño no debería permitir.

Segunda mitad de la misma historia: incluso en una corrida limpia donde las dos sentencias tienen éxito, nada le avisaba a project.md que algo había pasado. El archivo espejo que vive en la carpeta del proyecto seguía diciendo **Status:** Active después de que el ítem se guardara en bóveda, la base de datos decía una cosa, el archivo en disco decía otra, y el propio comentario de documentación del espejo promete “siempre refleja el estado actual.” No lo hacía.

El wrapper de la base de datos guarda su conexión por valor detrás de &self, y cada método de escritura ya es fn foo(&self, ...). rusqlite tiene una API de transacciones para exactamente esa forma, unchecked_transaction(&self), no la versión verificada transaction(&mut self), así que envolver el par no costó nada más que un pequeño helper:

pub fn with_tx<T>(&self, f: impl FnOnce() -> crate::Result<T>) -> crate::Result<T> {
    let tx = self.conn.unchecked_transaction()?;
    let result = f()?;
    tx.commit()?;
    Ok(result)
}

Las dos sentencias ahora corren dentro de un solo closure pasado a with_tx. Un crash a mitad de camino hace rollback en vez de dejar media decisión registrada.

La regeneración del espejo tiene una trampa más filosa escondida adentro. El arreglo obvio es “hacer commit de la transacción, después regenerar project.md, y devolver un error si eso también falla.” Pero si ese error se propaga y quien llama reintenta la llamada completa a vault_item, el reintento vuelve a correr set_vaulted_at(now) con un timestamp posterior, empujando en silencio el reloj de inactividad hacia adelante en un ítem que ya estaba en bóveda. Un mecanismo de seguridad terminaría reseteando su propio temporizador porque una escritura de archivo tuvo un tropiezo. Así que la regeneración del espejo es deliberadamente best-effort: registrar la falla, no fallar la llamada. La base de datos es la fuente de verdad; el archivo markdown es una vista reconstruible de ella, no al revés.

El mismo tratamiento se aplicó a los otros dos puntos de escritura múltiple del motor, registrar un touch, y promover una idea a proyecto activo (de tres a cinco sentencias: estado, etapa, nombre de carpeta, y un touch opcional), más un índice UNIQUE sobre la columna que le asigna a cada ítem su carpeta, que hasta ahora solo lo verificaba el código de la aplicación antes de insertar, nunca lo aplicaba la propia base de datos.

Cada bug anterior de este arco era “el código dice una cosa y hace otra” en una sola corrida determinista, se podía encontrar leyendo con atención una vez. Este solo existe en el hueco entre dos sentencias, y solo a veces. Las pruebas no pueden inyectar un crash a mitad de una transacción para probarlo, así que lo que en verdad podía verificar era el mecanismo: una transacción que devuelve Err hace rollback, una que devuelve Ok hace commit, y se registra un timestamp real cuando tiene éxito. Algunos arreglos se verifican probando la falla. Algunos se verifican probando que el modo de falla ya no tiene una rendija por donde colarse.

La tasa calculada en dos lenguajes

La misma revisión que encontró la escritura de bóveda a medio confirmar también encontró una versión más chica y callada de la misma familia de bug, no “el código se contradice a sí mismo,” sino “el código y los comentarios que lo describen se contradicen,” tres veces separadas.

Acá está la más filosa. Greenhouse rastrea una intuición suave: ¿los proyectos con nota de traspaso se terminan con más frecuencia que los que no la tienen? El lado de Rust calcula esto y expone un método:

pub fn notes_help(&self) -> bool {
    match (self.finish_rate_with_notes(), self.finish_rate_without_notes()) {
        (Some(with), Some(without)) => with > without,
        _ => false,
    }
}

Excepto que los métodos de impl no cruzan el IPC. Solo los campos planos se serializan. Así que los cuatro conteos crudos llegaban al frontend, y el booleano que en realidad todos querían no llegaba, lo cual significaba que el dashboard necesitaba el veredicto, y alguien tuvo que escribirlo de nuevo:

export function notesHelp(insight: HandoffInsight): boolean {
  const withRate = rate(insight.finished_with_notes, insight.concluded_with_notes);
  const withoutRate = rate(insight.finished_without_notes, insight.concluded_without_notes);
  if (withRate === null || withoutRate === null) return false;
  return withRate > withoutRate;
}

Misma comparación, mismos casos borde, dos archivos, dos lenguajes. Era correcto el día en que se escribió, los dos lados incluso tenían el mismo cuidado con el mismo null-guard. Pero “correcto hoy” nunca fue el riesgo. El riesgo era el día en que alguien cambiara la regla de Rust, agregara un umbral de diferencia mínima, cambiara el desempate, y editara solo uno de los dos lugares, porque nada obliga a acordarse de que el otro existe.

Este se puede arreglar de raíz en vez de solo parchearlo: hacer que Rust lo calcule una sola vez y entregue la respuesta.

pub struct HandoffInsight {
    // ...four counts...
    pub notes_help: bool,   // computed once, serialized, done
}

derive_handoff_insight lo fija después de sumar; la función de TypeScript y su helper privado rate() se borran directamente, y el dashboard lee daily.handoff_insight.notes_help como cualquier otro campo. Ya no hay una segunda implementación que pueda divergir, no “mantenida sincronizada,” genuinamente una sola fuente.

Dos primos más chicos

La misma revisión encontró dos casos más de “la descripción y el comportamiento no coinciden”:

config.yaml siempre tuvo un seed_quota, con las palabras del propio PRD: “un empujón del dashboard hacia un lote inicial.” La advertencia de inventario bajo que se supone debe leer ese número en cambio tenía LOW_INVENTORY_THRESHOLD = 3 fijo. Editar el archivo de configuración para subir la cuota a 10 no hacía nada; el aviso seguía disparándose en 3 de todas formas. Conectarlo al valor real de la configuración es un arreglo de una línea, pero el hecho de que un valor de configuración con un propósito escrito quedara sin leerse durante el trabajo de motor de cuatro issues es exactamente la forma de “declarado pero no aplicado” de unas secciones atrás, solo que con un archivo de configuración en vez de un PRAGMA.

Y un comentario de documentación sobre la acción de promoción afirmaba que “empieza a enfriar de inmediato.” No lo hace, y no lo hace desde que un issue anterior cambió deliberadamente ese valor por defecto, había hasta una prueba pasando que probaba lo contrario. Al comentario simplemente nunca le avisaron.

No todos estos necesitaban el mismo arreglo. El parámetro de configuración se conectó. El comentario se corrigió. Pero un tercer campo, vault_review_cadence_days, también parseado, también sin usar, lo dejé sin tocar a propósito: hacer que en verdad hiciera algo significaría inventar una forma de recordar “cuándo se mostró esto por última vez,” algo que la app no rastrea en ningún lado, y aproximarlo con la fecha de hoy se saltaría en silencio un aviso en un producto cuyo modelo entero es nunca bloquear, nunca saltarse nada, solo recordar con delicadeza. A veces el arreglo honesto para un campo sin uso es un comentario que explica por qué sigue sin usarse, no una funcionalidad que nadie pidió.

Un archivo de configuración que podía dejar la app sin arrancar antes de que se abriera la ventana

La misma revisión encontró un tercero, y este es el más filoso del lote, porque no se trata del código mintiéndose a sí mismo, se trata de una ruta de código que solo existe para manejar “el archivo en disco no es lo que se espera,” y en realidad no lo manejaba.

Greenhouse persiste un pequeño archivo fuera de la bóveda: settings.json, que guarda la ruta hacia donde sea que viva la bóveda del usuario. Se lee una vez, al arrancar, antes de que se abra la ventana:

pub fn load(config_dir: &Path) -> crate::Result<Self> {
    let path = config_dir.join(SETTINGS_FILE);
    if !path.exists() {
        return Ok(Self::default());
    }
    let content = std::fs::read_to_string(&path)?;
    let settings = serde_json::from_str(&content)?;
    Ok(settings)
}

El archivo faltante está manejado, una instalación nueva no tiene settings.json, y eso está bien, significa “mostrar onboarding.” Pero un archivo malformado no es lo mismo que uno faltante, y el código los trataba como si fueran modos de fallo intercambiables cuando en realidad solo uno de ellos lo era. Una escritura truncada por un crash a mitad del guardado, o una edición manual curiosa, produce un archivo que existe pero no parsea, y ese ? propaga el error de parseo directo hacia afuera.

Acá está la parte que lo vuelve filoso en vez de solo molesto: esta función se llama desde adentro del hook .setup() de Tauri.

.setup(|tauri_app| {
    app::load_startup_settings(tauri_app.handle())?;
    Ok(())
})

Un ? dentro de un command handler solo rechaza esa llamada puntual, el resto de la app sigue corriendo, el usuario ve un aviso de error. Un ? dentro de .setup() es un animal completamente distinto: aborta el arranque. No es “el dashboard falla al cargar.” No es “el onboarding muestra un error.” La ventana nunca se abre. La única forma de volver a entrar era encontrar settings.json en el disco y borrarlo a mano, fuera de la app, un paso de depuración que nada en la interfaz podía indicar, porque la interfaz nunca llegó a existir.

La misma pregunta de siempre: ¿qué fallas son “esto específico está roto” contra “no se puede confiar en la entrada, empezar de nuevo”? Un archivo faltante ya había respondido esa pregunta: usar el valor por defecto y seguir. Un archivo corrupto merece la misma respuesta, por la misma razón: la app no tiene forma de distinguir “el disco del usuario tiene un problema real” de “una escritura se interrumpió,” y usar el valor por defecto es seguro en cualquier caso, ya que el usuario simplemente vuelve a pasar por el onboarding hacia la bóveda que ya tenía.

Lo que no recibe el mismo trato: el paso de lectura de archivo antes del parseo. Un error de IO ahí, permisos, un volumen faltante, una falla real de disco, es una clase de problema distinta, una que el usuario necesita ver, no una que la app deba tapar en silencio. Así que la captura queda acotada de forma estricta, solo al paso de parseo:

let content = std::fs::read_to_string(&path)?;   // IO error: still propagates
match serde_json::from_str(&content) {
    Ok(settings) => Ok(settings),
    Err(e) => {
        eprintln!("Settings::load: corrupt {SETTINGS_FILE} ({e}), falling back to defaults");
        Ok(Self::default())                       // parse error: falls back
    }
}

La revisión hizo una pregunta justa en la otra dirección: ¿la función equivalente para el archivo de configuración de la bóveda, config.yaml, no settings.json, tiene el mismo bug? No lo tiene, pero no porque nadie lo hubiera pensado de antemano. Config::load nunca se llama desde .setup(), solo desde command handlers individuales, así que sus errores de parseo ya eran solo promesas rechazadas, ya eran solo errores de dashboard. El arreglo ahí no fue un arreglo, fue una prueba que fija que esto se mantenga así, el tipo de cosa que hoy es obviamente correcta y en silencio deja de serlo el día en que alguien conecte la carga de configuración al arranque por una buena razón y se olvide de que existía esta restricción.

“Manejar el caso faltante” y “manejar el caso corrupto” parecen el mismo problema hasta que se pregunta qué pasa después, y en este caso, “qué pasa después” era la app entera fallando al arrancar.

El comando que abría cualquier cosa que el webview le pidiera

El último hallazgo de la revisión en este grupo no era un bug de lógica sutil. Era lo opuesto: una capacidad a plena vista, haciendo exactamente lo que estaba escrita para hacer, y lo que estaba escrita para hacer era demasiado.

Antes en este arco, redirigí la apertura de carpetas por un comando de Rust en vez de la API de JS del plugin opener, específicamente para evitar una allowlist de scope vacía que negaba en silencio cada llamada de apertura. El arreglo funcionó. Lo que no consideré en ese momento: la allowlist de scope no solo estaba en el camino, era lo único que verificaba que la ruta que se abría fuera una que la app en verdad quisiera abrir.

#[tauri::command]
pub fn open_folder(app: AppHandle, path: String) -> Result<(), String> {
    open_dir(&app, path)
}

Eso es el comando entero. Entra una cadena desde el webview, y va directo al lanzador de manejador por defecto del sistema operativo, sin verificar que esté dentro de la bóveda, sin verificar siquiera que sea una ruta real en esta máquina. open_path es open(1) con pasos extra: darle un bundle .app, un script con la extensión correcta, una URL file:// hacia algo inesperado, y el sistema operativo decide qué significa “abrir” eso. Esta es, cuantitativamente, la capacidad más grande que la app le entrega a su propio webview, más grande que cualquier consulta SQL, porque las consultas SQL se quedan dentro de un esquema y esto no se queda dentro de nada.

Solo tenía un llamador. El diálogo de confirmación de captura guarda la ruta que Rust acaba de devolver al crear una carpeta de idea, y pasa esa misma cadena a “Mostrar en Finder.” Completamente legítimo hoy. Pero legítimo-hoy no es la propiedad que importa para una función alcanzable desde un webview comprometido o con bugs, la firma misma de la función es el límite de seguridad, no quién la llama hoy de forma responsable.

La app ya tenía la forma correcta sentada al lado de la incorrecta. open_item_folder(id) toma un id de ítem, busca su carpeta en la base de datos, y abre eso, la ruta nunca es algo que el frontend entrega, solo algo que el backend resuelve a partir de datos que ya posee. El frontend físicamente no puede pedirle a este comando que abra algo fuera de la bóveda, porque nunca llega a especificar una ruta en absoluto.

El único inconveniente: el diálogo de confirmación también necesitaba la ruta cruda, para un texto de “acá es donde se guardan los archivos” y un botón de copiar al portapapeles. Perder eso sería una regresión real de UX, no solo un refactor. Así que el valor de retorno del comando de captura ganó un campo en vez de perder uno, ya devolvía una cadena de ruta, ahora devuelve el id junto con ella:

#[derive(Debug, Serialize)]
pub struct CapturedIdea {
    pub id: String,
    pub folder_path: String,
}

La ruta sigue fluyendo hacia el bloque de visualización y el botón de copiar, exactamente igual que antes, mostrarle a un usuario la ruta de su propio archivo en su propia app nunca fue el riesgo. Solo la acción “abrir” cambió de manos, de openFolder(savedPath) a openItemFolder(savedId). Mismo botón, mismo comportamiento desde la perspectiva del usuario, un primitivo de ejecución arbitraria menos alcanzable desde JavaScript.

Reflexión

Ninguno del trío original se habría disparado hoy. El estado que desaparecía, la degradación silenciosa, los límites que no coincidían, todos latentes, todos esperando que un cambio futuro los pisara. El tipo de cosa que las pruebas no atrapan, porque todavía no hay código que probar. Lo que los atrapó fue tratar “esto parece ya terminado” como una razón para leer con más cuidado, no menos.

Y los hallazgos de la revisión que siguieron mantuvieron el mismo patrón de principio a fin: algo que parece manejado resulta estar manejado para el caso en el que alguien pensó, no para el caso que en verdad importa. Un estado que nada escribió. Un ordenamiento que corría al revés, y uno que nunca ordenaba. Una restricción que no restringía. Media decisión sin confirmar. Una tasa calculada en dos lenguajes. Un archivo de configuración que podía dejar la app sin arrancar. Y un comando que hace precisamente lo que su docstring promete, donde la promesa era el bug.

La lección que sigo reaprendiendo: cuando un sistema codifica la misma idea de dos formas, una de las dos eventualmente está mal. Elegir una. Borrar la otra.

Lecturas relacionadas

Development

Building Greenhouse: la bóveda en disco

La estructura de carpetas que sobrevive a la app: zonas versus etapas, una configuración que se niega a sobrescribirse, un archivo que es una impresión de la base de datos, importación por adopción en el lugar, y el parámetro que no hacía nada.

Leer