Pular para o conteúdo
Development

O status que nada nunca escreveu (dev log do motor do Greenhouse)

Por Victor Da Luz
rustsqlitetauridev-loggreenhouse

Estou construindo o Greenhouse, um app desktop que impõe uma cadência de processo criativo, capturar ideias, deixar elas descansarem, alternar entre projetos, nunca deletar nada. O motor de regras é em Rust, e uma sessão anterior já tinha entregado ele: 22 testes passando, build limpo, issue fechada.

Então, quando a próxima issue surgiu, “definir o modelo de estado por item”, ela parecia pronta antes mesmo de eu começar. O modelo estava bem ali em state.rs. Cinco valores de status, um cooldown derivado, uma flag de maturidade. Todo campo que a issue pedia.

Quase fechei como duplicata. Em vez disso, li o código.

Investigação

O status do item era um enum:

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

MatureIdea é o interessante. Uma ideia começa fresca e vira “madura” depois de um timer, ponto em que você deve decidir: trabalhar nela, ou guardar no vault. Um valor de status parece razoável.

Só que nada nunca o definia.

Rodei grep na crate inteira. O caminho de promoção ia de FreshIdea → Active direto. Nada transicionava algo para MatureIdea. E o dashboard diário, a coisa que mostra “ideias que precisam de uma decisão”, consultava só ideias frescas:

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

Aqui está a armadilha. Se algum código futuro de fato definisse um item como MatureIdea, e o enum convida a isso, porque está bem ali, esse item sumiria. A lista de trabalho ativa o exclui. A consulta de ideias-a-decidir não o puxa. Ele estaria no banco de dados, com um status válido, e invisível em todo lugar.

A maturidade estava modelada duas vezes: uma como um booleano derivado calculado a partir de um timestamp, outra como um status armazenado. As duas nunca precisavam concordar, então eventualmente não concordariam.

Aí encontrei a segunda. A função que lê um status de volta do SQLite:

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

Qualquer string não reconhecida vira uma ideia fresca. Na maioria dos apps isso é um padrão preguiçoso mas inofensivo. Num app de não deletar é um desastre silencioso: uma linha Released corrompida ou parcialmente migrada volta silenciosamente a ser uma ideia fresca e reentra no pipeline de captura. A única promessa que o produto faz, nada é perdido ou alterado sem você saber, desfeita por um branch de fallback.

A correção

Escolha um modelo. Maturidade é derivada, ponto final. O motor é uma calculadora de estado, não um porteiro, ele responde “o que é verdade agora” a partir de timestamps. Então um MatureIdea armazenado não tinha razão de existir. Removi. Uma ideia madura agora é só uma ideia fresca cujo maturity_reached calculado é verdadeiro.

E o desserializador para de chutar:

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

Status desconhecido falha alto. Para um produto de não deletar, falhar alto é o comportamento correto.

Pós-escrito: o off-by-one-second que não era consistente

Com o modelo limpo, passei para os problemas de timer, cooldown e maturidade, esperando mais do mesmo trabalho de “já construído, só verificar”. Estavam construídos e testados. Mas comparando lado a lado, discordavam numa coisa minúscula: o limite.

O cooldown perguntava: isso ainda está trancado?

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

A maturidade perguntava: já passou tempo suficiente?

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

Um era estrito, o outro era inclusivo. No segundo exato em que o timer expira, o cooldown dizia “disponível agora” enquanto a maturidade dizia “madura agora”, convenções opostas para a mesma ideia, “um período de tempo passou desde um evento”. As duas especificações usavam a mesma palavra: depois. Disponível depois do cooldown. Madura depois do timer.

Então fiz elas concordarem na leitura estrita. O estado muda estritamente depois que o período completo se passa; no tick exato do limite, você ainda está no estado “antes” por mais um segundo:

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

Um segundo num timer de 7 dias não muda nada que um usuário jamais vai notar. Mas o trabalho inteiro de um motor de regras é temporização previsível, e dois timers usando matemática de limite diferente é o tipo de coisa que morde depois, alguém escreve “as regras de cooldown e maturidade têm a mesma forma”, e silenciosamente não têm. Agora têm. Cada mudança veio com um teste fixando o limite exato, para que a próxima pessoa não consiga desalinhar elas por acidente.

Uma estatística que precisa merecer seu lugar

O mecanismo de touch, a ação “trabalhei nisso” que inicia um cooldown e opcionalmente recebe uma nota de handoff, já estava construído. Mas a issue dele tinha uma linha mais discreta que eu tinha passado batido: rastrear se projetos com notas de handoff terminam com mais frequência, e mostrar isso com sutileza.

O dado já estava lá. Todo touch guarda sua nota. Então eu conseguia comparar: dos projetos que chegaram a uma conclusão, os com notas terminaram mais que os sem?

A versão fácil se escreve sozinha, conta as conclusões em cada grupo, divide, mostra a porcentagem. A armadilha é mostrar isso cedo demais. Com dois projetos concluídos, “100% de quem deixa nota termina!” não é um achado, é ruído vestindo jaleco. E essa é uma ferramenta sobre ser honesto consigo mesmo; um insight falso é pior do que nenhum.

Então a estatística se auto-restringe:

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

E o dashboard só carrega isso quando é merecido:

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

Abaixo do limite, o campo é None e a UI não mostra nada. “Mostrar com sutileza” acabou significando “na maior parte do tempo, não mostrar de jeito nenhum”. O trabalho principal dessa funcionalidade é saber quando ficar quieta.

Mais uma decisão pequena: o que conta como um “projeto que concluiu”? Fui com estados terminais só, Released (terminado) ou Vaulted (guardado). Um projeto Active ainda não escolheu, então contar ele como “não terminado” puxaria toda taxa para zero e faria as notas parecerem inúteis. Resultados só são comparados quando de fato são resultados.

A ordenação que rodava ao contrário

O dashboard diário do Greenhouse mostra uma “lista de trabalho ativa”, os projetos que você pode pegar hoje. O ponto inteiro do app é rotação: ele te empurra para o trabalho que você tem negligenciado e, através do cooldown, para longe de qualquer coisa que você acabou de tocar. Então a lista de trabalho deveria colocar seu projeto mais negligenciado no topo.

O código ordenava assim:

ORDER BY last_touched DESC NULLS LAST

Isso é trabalhado-mais-recentemente primeiro, nunca-tocado por último. Exatamente ao contrário. O projeto que você ignorou por um mês fica no fundo; o que você acabou de terminar de esfriar fica no topo. O comentário três linhas acima até dizia “itens negligenciados sobem”, o código estava fazendo o oposto da própria descrição.

Nada pegou isso porque o único teste checava quais itens estavam na lista, nunca a ordem deles:

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

Um item entra, um item sai, a ordem é invisível com uma amostra de tamanho um. Ordenações são o lugar clássico onde isso se esconde: a lista parece certa, está cheia das coisas certas, e está de cabeça para baixo.

A correção é uma linha, mais um teste que de fato fixa a sequência:

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

Se uma ordenação importa, afirme a ordem, não só o conteúdo.

A ordenação que não estava ordenando

Essa ordenação ao contrário teve uma sequência, e é a mesma linha de SQL usando um disfarce diferente.

O Greenhouse roda uma revisão diária “Rescue or Keep”: uma vez por dia ele mostra um projeto guardado no vault que já ficou tempo suficiente para reconsiderar. A regra é “me mostre o que está dormente há mais tempo”. O código pegava todos os itens guardados e usava o primeiro 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);

“Primeiro maduro” só significa alguma coisa se a lista está ordenada. E list_items_by_status ordena pela mesma coluna de antes, last_touched. Para projetos ativos isso faz sentido. Para os guardados no vault não é nada: um projeto no vault não está sendo tocado, então seu last_touched é NULL e permanece NULL.

Então toda linha guardada no vault ordena pelo mesmo valor idêntico, NULL, cai num único balde NULLS LAST, e o SQLite devolve elas na ordem que bem entender. .find() pega o primeiro item maduro de uma pilha arbitrária. A ordenação ao contrário ao menos ordenava, essa não tinha ordenação nenhuma. Uma chave que é constante entre suas linhas é o mesmo que nenhuma chave.

Parecia estável na minha máquina com poucas linhas. Mesma armadilha de antes, um grau mais silenciosa: “arbitrário” não promete “errado toda vez”, promete “sem promessa nenhuma”. Adicione uma linha ou rode numa build diferente do SQLite e o lembrete diário silenciosamente aponta para outro lugar.

O valor que eu queria já estava no schema, vaulted_at, o momento em que o item entrou no vault. O mais dormente há mais tempo é só o menor. Então os itens guardados no vault ganharam sua própria consulta em vez de pegar emprestada a da lista de trabalho:

ORDER BY vaulted_at ASC NULLS LAST

Guardado há mais tempo primeiro; o .find(maturity_reached) em cima agora produz o item maduro mais dormente por construção. O teste insere três itens maduros fora de ordem mais um recente demais e fixa qual deles aparece, porque, igual à ordenação ao contrário, um bug de ordem é invisível até um teste afirmar a ordem.

Mesma consulta, terceira lição tirada dela: ORDER BY numa coluna que é NULL exatamente para as linhas que você está selecionando não está ordenado, é “o que o motor devolver”, e vai parecer determinístico até não parecer mais.

A restrição que não estava restringindo

Mais uma, e é minha favorita: um trilho de segurança que parece instalado mas não está parafusado em nada.

O schema declara que todo touch pertence a um item real:

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

Esse REFERENCES items(id) lê como uma garantia: você não pode registrar um touch para um item que não existe. Só que o SQLite vem com chaves estrangeiras desligadas por padrão. A imposição só liga se você rodar PRAGMA foreign_keys = ON na conexão, e nada rodava. Então a restrição era documentação, não uma regra. Você podia inserir um touch apontando para um item fantasma e o banco de dados aceitaria numa boa.

Não tinha mordido ainda porque o único caminho de código que escreve touches checa que o item existe primeiro. Mas essa é uma defesa que descansa inteiramente no código da aplicação lembrar de tomar cuidado, exatamente o que a restrição do banco deveria tornar desnecessário.

A correção é uma linha, definida em toda conexão na abertura:

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

E um teste que tenta inserir um órfão e espera uma rejeição:

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

A declaração estava certa. A imposição estava faltando. Escrever algo não é o mesmo que ligar isso.

Meia decisão

Uma revisão do repositório encontrou algo que nenhum dos achados anteriores pegou, porque não falha em nenhuma execução isolada, só falha numa execução que para no meio. A ação “guardar um item no vault” do Greenhouse é dois statements SQL:

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

Cada um faz commit no instante em que roda, não existe transação em volta do par. Se o processo morrer entre os dois (um crash, um app finalizado à força, SQLITE_BUSY de outra coisa mexendo no arquivo), o primeiro statement sobrevive e o segundo não. O item agora é Vaulted com vaulted_at = NULL.

Esse NULL não é cosmético. A consulta de revisão de vault ordena itens guardados por vaulted_at para achar “o que está dormente há mais tempo”, o mesmo bug de ordenação que esse arco já cobriu uma vez, exceto que dessa vez a coluna inteira está faltando, não só desordenada. Um NULL ordena por último e nunca lê como maduro, então o item fica guardado no vault para sempre e nunca volta a aparecer no Rescue-or-Keep. Arquivado, silenciosamente, para sempre. Num app cuja premissa inteira é “nada nunca desaparece de verdade”, esse é o único resultado que o design não deveria permitir.

Segunda metade da mesma história: mesmo numa execução limpa em que os dois statements têm sucesso, nada avisava o project.md que algo tinha acontecido. O arquivo espelho sentado na pasta do projeto continuava lendo **Status:** Active depois que o item foi guardado no vault, o banco de dados dizia uma coisa, o arquivo no disco dizia outra, e o próprio comentário de documentação do espelho promete “sempre reflete o estado atual”. Não refletia.

O wrapper do banco de dados guarda sua conexão por valor atrás de &self, e todo método de escrita já é fn foo(&self, ...). O rusqlite tem uma API de transação exatamente para essa forma, unchecked_transaction(&self), não a checada transaction(&mut self), então envolver o par não custou nada além de um pequeno 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)
}

Os dois statements agora rodam dentro de uma closure passada para with_tx. Um crash no meio do caminho faz rollback em vez de deixar uma meia decisão registrada.

A regeneração do espelho tem uma armadilha mais afiada escondida nela. A correção óbvia é “fazer commit da transação, depois regenerar o project.md, e devolver um erro se isso também falhar”. Mas se esse erro se propagar e quem chamou tentar de novo a chamada inteira de vault_item, a nova tentativa roda set_vaulted_at(now) de novo com um timestamp mais tarde, empurrando silenciosamente o relógio de dormência para frente num item que já estava guardado no vault. Um mecanismo de segurança estaria resetando seu próprio timer porque uma escrita de arquivo engasgou. Então a regeneração do espelho é deliberadamente best-effort: registra a falha, não falha a chamada. O banco de dados é a fonte da verdade; o arquivo markdown é uma visão reconstruível dele, não o contrário.

O mesmo tratamento foi dado aos outros dois pontos de escrita múltipla no motor, registrar um touch, e promover uma ideia a um projeto ativo (três a cinco statements: status, estágio, nome de pasta, e um touch opcional), além de um índice UNIQUE na coluna que atribui a pasta de cada item, que até agora só era checado pelo código da aplicação antes de inserir, nunca imposto pelo próprio banco de dados.

Todo bug anterior nesse arco era “o código diz uma coisa e faz outra” numa execução única e determinística, você conseguia achar lendo com atenção uma vez. Esse só existe na lacuna entre dois statements, e só às vezes. Testes não conseguem injetar um crash num processo no meio de uma transação para provar isso, então o que eu de fato consegui verificar foi o mecanismo: uma transação que devolve Err faz rollback, uma que devolve Ok faz commit, e um timestamp real é gravado quando tem sucesso. Algumas correções você verifica testando a falha. Outras você verifica testando que o modo de falha não tem mais uma brecha para caber.

A taxa calculada em duas linguagens

A mesma revisão que encontrou a escrita de vault meio commitada também encontrou uma versão menor e mais discreta da mesma família de bug, não “o código se contradiz”, mas “o código e os comentários que o descrevem se contradizem”, três vezes separadas.

Aqui está a mais afiada. O Greenhouse rastreia um insight leve: projetos com uma nota de handoff terminam com mais frequência do que os sem? O lado Rust calcula isso e expõe um 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,
    }
}

Só que métodos impl não atravessam IPC. Só campos simples serializam. Então as quatro contagens brutas chegaram ao frontend, e o booleano que todo mundo de fato queria não chegou, o que significava que o dashboard precisava do veredito, e alguém teve que escrever de novo:

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;
}

Mesma comparação, mesmos casos extremos, dois arquivos, duas linguagens. Estava correto no dia em que foi escrito, os dois lados até tomaram cuidado com a mesma proteção contra null. Mas “correto hoje” nunca foi o risco. O risco era o dia em que alguém mudasse a regra em Rust, adicionasse um limite mínimo de diferença, mudasse o desempate, e editasse só um dos dois lugares, porque nada obriga a lembrar que o outro existe.

Esse dá para corrigir na raiz em vez de só remendar: fazer o Rust calcular uma vez e entregar a resposta.

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

derive_handoff_insight define isso depois de contar; a função TypeScript e seu helper privado rate() são deletados de vez, e o dashboard lê daily.handoff_insight.notes_help como qualquer outro campo. Não existe mais uma segunda implementação para desalinhar, não “mantida em sincronia”, genuinamente de fonte única.

Duas primas menores

A mesma revisão encontrou mais duas instâncias de “a descrição e o comportamento discordam”:

O config.yaml sempre carregou um seed_quota, as próprias palavras do PRD para isso são “um empurrão do dashboard rumo a um lote inicial”. O aviso de estoque baixo que deveria ler esse número, em vez disso, tinha LOW_INVENTORY_THRESHOLD = 3 fixo no código. Editar seu arquivo de config para subir sua quota para 10 não fazia nada; o aviso continuava disparando em 3 de qualquer jeito. Conectar isso ao valor real da config é uma correção de uma linha, mas o fato de que um valor de config com um propósito documentado ficou sem ser lido por quatro issues de trabalho no motor é exatamente a forma “declarado mas não imposto” de algumas seções atrás, só vestindo um arquivo de configurações em vez de um PRAGMA.

E um comentário de documentação na ação de promover afirmava que ela “começa a esfriar imediatamente”. Não começa, e não começa desde que uma issue anterior deliberadamente inverteu esse padrão, existia até um teste passando provando o oposto. O comentário simplesmente nunca foi avisado.

Nem todos esses precisaram da mesma correção. O botão de config foi conectado. O comentário foi corrigido. Mas um terceiro campo, vault_review_cadence_days, também interpretado, também não usado, deixei de propósito: fazer isso realmente fazer alguma coisa significaria inventar um jeito de lembrar “quando isso foi mostrado pela última vez”, que o app não rastreia em lugar nenhum, e aproximar isso com a data de hoje pularia silenciosamente um lembrete num produto cujo modelo inteiro é nunca bloquear, nunca pular, só lembrar com sutileza. Às vezes a correção honesta para um campo não usado é um comentário explicando por que ele continua não usado, não uma funcionalidade que ninguém pediu.

Um arquivo de configurações que podia quebrar o app antes da janela abrir

A mesma revisão encontrou um terceiro, e esse é o mais afiado do lote, porque não é sobre o código mentindo para si mesmo, é sobre um caminho de código que só existe para lidar com “o arquivo no disco não é o que esperamos”, e ele não lidava com isso de fato.

O Greenhouse persiste um pequeno arquivo fora do vault: settings.json, guardando o caminho para onde quer que o vault do usuário viva. Ele é lido uma vez, na inicialização, antes da janela abrir:

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)
}

Arquivo faltando é tratado, uma instalação nova não tem settings.json, e tudo bem, significa “mostrar onboarding”. Mas um arquivo malformado não é o mesmo que um faltando, e o código tratava os dois como se fossem modos de falha intercambiáveis quando só um deles de fato era. Uma escrita truncada por um crash no meio do salvamento, ou uma edição manual curiosa, produz um arquivo que existe mas não faz parse, e aquele ? propaga o erro de parse direto para fora.

Aqui está a parte que tornou isso afiado em vez de só irritante: essa função é chamada de dentro do hook .setup() do Tauri.

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

Um ? dentro de um manipulador de comando só rejeita aquela chamada, o resto do app continua rodando, o usuário vê um banner de erro. Um ? dentro de .setup() é um animal completamente diferente: ele aborta a inicialização. Não “o dashboard falha ao carregar”. Não “o onboarding mostra um erro”. A janela nunca abre. O único jeito de voltar era achar o settings.json no disco e deletar na mão, fora do app, um passo de depuração que nada na UI conseguia te dizer para fazer, porque a UI nunca teve a chance de existir.

Mesma forma de pergunta de sempre: quais falhas são “essa coisa específica está quebrada” contra “não podemos confiar na entrada, comece de novo”? Um arquivo faltando já respondeu essa pergunta, use o padrão e siga em frente. Um arquivo corrompido merece a mesma resposta, pela mesma razão: o app não tem como distinguir “o disco do usuário tem um problema real” de “uma escrita foi interrompida”, e usar o padrão é seguro de qualquer jeito, já que o usuário só faz onboarding de novo no vault que já tinha.

O que não recebe o mesmo tratamento: o passo de leitura do arquivo acima do parse. Um erro de IO ali, permissões, um volume faltando, uma falha real de disco, é uma classe diferente de problema, um que o usuário precisa ver, não um que o app deveria silenciosamente esconder. Então o catch é limitado com precisão, só ao passo de parse:

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
    }
}

A revisão fez uma pergunta justa na outra direção: a função equivalente para o arquivo de config do vault, config.yaml, não settings.json, tem o mesmo bug? Não tem, mas não porque alguém pensou nisso de antemão. Config::load nunca é chamado a partir de .setup(), só de manipuladores de comando individuais, então os erros de parse dele já eram só promessas rejeitadas, já eram só erros de dashboard. A correção ali não foi uma correção, foi um teste que fixa que isso continua verdade, o tipo de coisa que é obviamente correta hoje e silenciosamente para de ser correta no dia em que alguém conectar o carregamento de config à inicialização por um bom motivo e esquecer que essa restrição existia.

“Lidar com o caso faltando” e “lidar com o caso corrompido” parecem o mesmo problema até você perguntar o que acontece depois, e nesse caso, “o que acontece depois” era o app inteiro falhando ao iniciar.

O comando que abriria qualquer coisa que a webview pedisse

O último achado da revisão nesse conjunto não era um bug sutil de lógica. Era o oposto: uma capacidade sentada à vista de todos, fazendo exatamente o que foi escrita para fazer, e o que foi escrita para fazer era demais.

Antes nesse arco, roteei a abertura de pastas através de um comando Rust em vez da API JS do plugin opener, especificamente para contornar uma allowlist de escopo vazia que estava silenciosamente negando toda chamada de abertura. A correção funcionou. O que não considerei na hora: a allowlist de escopo não estava só no caminho, era a única coisa checando que o caminho sendo aberto era um que o app de fato pretendia abrir.

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

Esse é o comando inteiro. Uma string entra vinda da webview, e vai direto para o lançador de manipulador padrão do sistema operacional, nenhuma checagem de que está dentro do vault, nenhuma checagem de que sequer é um caminho real nessa máquina. open_path é open(1) com passos extras: dê a ele um pacote .app, um script com a extensão certa, uma URL file:// para algo inesperado, e o sistema operacional decide o que “abrir” significa. Essa é, quantitativamente, a maior capacidade única que o app entrega à própria webview, maior que qualquer consulta SQL, porque consultas SQL ficam dentro de um schema e isso não fica dentro de nada.

Só tinha um chamador. O diálogo de confirmação de captura guarda o caminho que o Rust acabou de devolver ao criar uma pasta de ideia, e passa essa mesma string para “Show in Finder”. Completamente legítimo hoje. Mas legítimo-hoje não é a propriedade que importa para uma função alcançável por uma webview comprometida ou com bug, a própria assinatura da função é o limite de segurança, não quem atualmente a chama de forma responsável.

O app já tinha a forma certa sentada ao lado da errada. open_item_folder(id) recebe um id de item, busca sua pasta no banco de dados, e abre isso, o caminho nunca é algo que o frontend entrega, só algo que o backend resolve a partir de dados que já possui. O frontend fisicamente não consegue pedir a esse comando para abrir nada fora do vault, porque nunca chega a especificar um caminho.

O único empecilho: o diálogo de confirmação também precisava do caminho bruto, para uma exibição de “é aqui que seus arquivos vão ser salvos” e um botão de copiar para a área de transferência. Perder isso seria uma regressão de UX real, não só uma refatoração. Então o valor de retorno do comando de captura ganhou um campo em vez de perder um, já devolvia uma string de caminho, agora devolve o id junto:

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

O caminho ainda flui para o bloco de exibição e o botão de copiar, exatamente como antes, mostrar a um usuário o próprio caminho de arquivo no próprio app nunca foi o risco. Só a ação de “abrir” mudou de mãos, de openFolder(savedPath) para openItemFolder(savedId). Mesmo botão, mesmo comportamento do ponto de vista do usuário, uma primitiva de execução arbitrária a menos alcançável a partir de JavaScript.

Reflexão

Nenhum do trio original teria disparado hoje. O status que sumia, o downgrade silencioso, os limites desencontrados, tudo latente, tudo esperando uma mudança futura pisar em cima. O tipo de coisa que testes não pegam, porque ainda não existe código para testar. O que pegou isso foi tratar “isso parece já pronto” como um motivo para ler com mais cuidado, não menos.

E os achados da revisão que vieram depois mantiveram o mesmo padrão até o fim: algo que parece tratado acaba sendo tratado para o caso em que alguém estava pensando, não o caso que de fato importa. Um status que nada escreveu. Uma ordenação que rodava ao contrário, e outra que nunca ordenava. Uma restrição que não restringia. Meia decisão deixada sem commit. Uma taxa calculada em duas linguagens. Um arquivo de configurações que podia quebrar o app. E um comando que faz precisamente o que sua documentação promete, onde a promessa era o bug.

A lição que continuo reaprendendo: quando seu sistema codifica a mesma ideia de dois jeitos, um deles eventualmente está errado. Escolha um. Delete o outro.

Leitura relacionada

Development

Construindo o Greenhouse: o vault em disco

O layout de pastas que sobrevive ao app: zonas versus estágios, uma config que se recusa a sobrescrever, um arquivo retrato do banco de dados, e o parâmetro que não fazia nada.

Ler
Development

O scan que se ofereceu para importar a si mesmo

Um scanner de importação que encontrou a própria estrutura interna do app, e depois voltou a oferecer uma pasta que tinha acabado de importar, duas versões da mesma conversa que faltava entre camadas.

Ler

Você também pode achar útil

Proton

Proton VPN

VPN comercial com filtragem NetShield e interruptor de desligamento automático.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
AdGuard

AdGuard para iOS

Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.

Como afiliado da AdGuard, ganho com compras qualificadas.

Saiba mais
Airalo

eSIM Airalo

eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.

Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.

Saiba mais