2024 — hoje
Xanadu Fleet
Inspeções que sobrevivem sem sinal
- Papel
- Autor único, perto de 300 commits
- Feito com
- Go, PostgreSQL, htmx, PWA, S3 Object Lock (WORM), Ed25519, Goose, GitHub Actions, MagaLU Cloud
O problema
Uma transportadora inspeciona cada veículo duas vezes: quando ele sai do pátio e quando volta. Esse processo era em papel. O motorista preenchia um formulário na prancheta, a folha ia para uma pasta e, quando alguém precisava dela — uma quebra, um sinistro, uma discussão sobre quem amassou a porta —, ela estava perdida, ilegível ou arquivada no mês errado.
Ninguém conseguia responder à única pergunta que importava: em que estado esse caminhão estava no dia 14, e quem assinou por ele?
O que o sistema faz
- Checklists digitais versionados — 58 itens para caminhão, 19 para máquina, carretas vinculadas à inspeção — executados na entrega e na devolução do veículo.
- Um PWA offline-first que as equipes de campo usam para registrar inspeções, fotos e abastecimentos sem conexão, sincronizando depois.
- Um admin web feito com
html/templatee htmx: ativos, usuários, relatórios, um painel do gestor, exportações em CSV. - Grupos de permissão — motorista, operador, gestor, administrador — editados no próprio sistema, de modo que ampliar o que um grupo pode fazer é uma concessão, não um deploy.
- Correções que substituem um registro em vez de editá-lo, e não conformidades que seguem para a viagem seguinte.
- Uma cadeia de hashes por tenant, assinada e ancorada em armazenamento write-once, para que os registros sejam evidência, e não opinião.
Dez Architecture Decision Records documentam as escolhas abaixo; três manuais — motorista, gestor e administrador — documentam o resultado.
Versionar o checklist veio primeiro
A primeira decisão de verdade não tinha nada a ver com funcionar offline. Era esta: se você edita um checklist, toda inspeção já preenchida com ele muda de significado silenciosamente. Uma inspeção que passou por 58 verificações passa a alegar que passou por 59 — e uma delas nunca foi olhada.
Então versões de checklist são imutáveis, e cada inspeção fixa a versão exata contra a qual foi executada. Acrescentar um item cria uma nova versão; os registros antigos continuam apontando para a versão antiga e continuam dizendo a verdade. Custa um join e um pouco de disciplina na tela de administração, e é a diferença entre um registro e um palpite.
Um registro nunca é editado, só substituído
Uma inspeção é evidência legal, então é imutável a partir do envio — as tabelas são append-only, com triggers que bloqueiam UPDATE e DELETE. Mas motoristas erram de verdade: uma leitura de hodômetro errada, um item marcado no lugar errado. O caminho de correção precisava consertar o dado sem tocar no registro.
Uma correção é uma nova inspeção que substitui uma anterior. Ela carrega uma referência ao registro que substitui; o original nunca é modificado e permanece no histórico, marcado como substituído. As páginas de detalhe apontam uma para a outra. Só o registro mais novo de uma cadeia pode ser corrigido, então existe sempre exatamente uma resposta atual.
A parte sutil é que o próprio vínculo de substituição é comprometido na cadeia de hashes — mas só quando existe. Registros feitos antes de existirem correções produzem o mesmo hash, byte a byte, e continuam verificando. O vínculo, portanto, não pode ser redirecionado nem escondido depois.
Offline-first, e o que isso significou de fato
O cliente móvel é um PWA, e não um app nativo, e o raciocínio foi medido, não presumido: a frota de motoristas é padronizada em Android, então a única fraqueza real de um PWA — o iOS descartar armazenamento não sincronizado — não se aplica. Um app nativo teria acrescentado uma conta em loja, um ciclo de revisão e uma segunda toolchain para um desenvolvedor solo manter, para atender uma frota que o PWA já atendia.
O que offline-first custou na prática:
- Uma fila durável em IndexedDB no aparelho, com armazenamento persistente solicitado para que o navegador não a descarte.
- Uma API REST de sincronização neutra em relação ao framework — puxa dados de referência, empurra inspeções — para que a escolha do cliente continue reversível sem mexer no servidor.
- Ingestão que tolera o mundo real: registros que chegam fora de ordem não podem fazer o hodômetro de um ativo andar para trás, e uma inspeção com relógio muito defasado é colocada em quarentena, em vez de confiada ou descartada.
- Fotos vão por um canal separado. O aparelho pede uma URL pré-assinada, envia os bytes ao object storage e depois registra a foto no seu registro — e o servidor confere que o hash declarado bate com o que de fato chegou. Um registro é aceito antes de suas fotos chegarem, então um checklist nunca fica refém de uma imagem lenta.
Levar um defeito adiante sem mentir sobre a foto
Quando um motorista inicia uma inspeção, todo item reprovado na inspeção anterior daquele caminhão volta pré-marcado. Para um defeito que tinha foto, o motorista escolhe: manter, atualizar a foto ou marcar como corrigido.
“Manter” é o caso interessante. As regras de evidência proíbem que uma foto antiga se passe por captura de hoje, mas obrigar a fotografar de novo uma trinca que não mudou é um tipo enganoso de honestidade. A solução foi reutilizar a foto por hash, com procedência datada: o novo resultado declara o mesmo hash de foto da inspeção anterior mais a data em que ela foi originalmente capturada, e essa procedência é selada na cadeia de hashes. O motorista vê a foto anterior de verdade antes de escolher — baixada e guardada durante a última sincronização, então funciona sem sinal — e o servidor recusa qualquer alegação de “trazida de” que a cadeia já não tenha comprometido para aquele ativo. Nada se passa por hoje sem ter sido reafirmado hoje.
Evidência que se pode entregar a um terceiro
O Postgres gerenciado do provedor de hospedagem não oferecia recuperação contínua point-in-time, e o registro de inspeção é a camada legal. Então o desenho trata o object storage como o log de eventos autoritativo e o Postgres como uma projeção reconstruível:
| Camada | Mecanismo |
|---|---|
| Prova de adulteração | Cadeia de hashes por tenant gravada na inserção: record_hash = sha256(payload canônico ‖ prev_hash ‖ seq), com prefixo de tamanho para que fronteiras de campo não colidam, e resultados ordenados para que o hash independa da ordem de inserção |
| Imutabilidade | Tabelas append-only impostas por triggers, não por convenção |
| Cópia fora da máquina | Um worker em segundo plano assina cada hash de registro com uma chave Ed25519 e o grava num bucket com Object Lock em modo compliance — uma retenção que nem o dono da conta consegue encurtar |
| Sinal de saúde | Atraso de ancoragem, max(seq) − último seq ancorado; zero significa que toda inspeção chegou à cópia imutável, e um atraso crescente é o alarme de backup |
| Checkpoints | Um resumo assinado da cabeça da cadeia uma vez por dia |
| Auditoria independente | Uma ferramenta evidence-verify que lê apenas o bucket e a chave pública publicada — sem banco de dados — para que um auditor ou um tribunal confira o log sem confiar no servidor |
O exercício de restauração é um runbook, e ele é executado. Backup não testado não conta.
Abastecimento, deliberadamente sem cadeia
Motoristas da divisão florestal abastecem no comboio do cliente e preenchem a requisição de papel do cliente; a empresa não guardava cópia digital própria. A captura de abastecimento reaproveita toda a máquina de campo — offline, IDs gerados no cliente, envio idempotente, foto obrigatória do canhoto selada no registro na captura — e registra litros de diesel e de ARLA contra a leitura do ativo.
É append-only, mas não faz parte da cadeia de hashes, e isso foi uma decisão, não um esquecimento. O cliente detém a cópia autoritativa de cada requisição; este registro é evidência de conciliação, não o registro legal primário, e encadeá-lo seria custo sem uma alegação por trás. O ADR nomeia o gatilho para revisitar: se o registro de abastecimento um dia virar evidência de faturamento, ganha a própria cadeia.
Abastecimentos suspeitos são sinalizados, nunca rejeitados. Litros que discordam da bomba, relógio defasado, ativo sem responsável — tudo é aceito e marcado na lista administrativa, porque um registro recusado no campo é um registro perdido.
Rodando em produção
| Preocupação | Como é tratada |
|---|---|
| Regressões | go vet, go build e go test a cada push, contra um Postgres novo |
| Mudanças de schema | Migrações com Goose, versionadas e no repositório — já passam de cinquenta |
| Deploys | Um workflow sob demanda constrói a imagem e instala por SSH; nada é publicado por acidente |
| Perder o banco | Snapshots mais o log assinado fora da máquina descrito acima |
| Dados pessoais | Exportações CSV com nomes de motoristas têm limite de taxa e são auditadas |
| Superfície de ataque no navegador | Uma Content-Security-Policy de base |
| Esquecer o porquê | Dez ADRs e três runbooks: deploy, recuperação de desastre, ancoragem de evidências |
Por que htmx, e não React
O admin é formulário e tabela. Todo estado que ele manipula vive no Postgres, e o servidor já sabe renderizá-lo. Partir para React significaria um build step, uma segunda cópia do modelo de domínio em TypeScript e um problema de sincronização que eu teria criado sozinho.
html/template com htmx dá ao admin atualizações parciais sem nada disso. A complexidade de cliente vai para onde ela é realmente inevitável: o PWA offline, que de fato precisa guardar estado, enfileirar trabalho e resolvê-lo depois.
Onde está agora
Perto de 300 commits. Desde junho, a interface inteira foi reconstruída a partir de um protótipo de design sobre uma única folha de estilos do design system; a página inicial virou um painel do gestor, com relatórios de combustível, por divisão e com drill-through; e o sistema passou a conciliar os próprios números com o ERP da empresa — resultado por filial, cruzando documentos de frete com os registros do ERP —, que é onde um sistema de frota deixa de ser um formulário e vira um argumento que se pode vencer.