A barraquinha de espetinho
Eu estava numa barraquinha de espetinho, dessas de calçada, e o cara fazia tudo sozinho. Atendia, anotava o pedido, virava a carne, cobrava, dava troco, e no meio disso tudo rabiscava num caderninho o que tinha saído. Cinco funções, um par de mãos.
O caderno estava encardido de gordura, com a espiral entortada. E funcionava perfeitamente.
Fiquei com aquilo na cabeça. Estamos em 2026. Desde o Egito antigo o ser humano registra coisas em suporte físico, e depois de todos esses milênios, e de uns bons quarenta anos de software comercial, o vendedor de rua continua escolhendo papel e caneta. Não por nostalgia. Por eficiência.
Isso ou é um mistério, ou é um diagnóstico. Eu apostei que era diagnóstico.
O que a pesquisa devolveu
Antes de escrever uma linha de código, fiz duas coisas.
Primeiro, conversei. Fui a feira, calçada, barraca de pastel, carrinho de lanche. Perguntei quem anota, quem não anota, e a pergunta que interessa: quem já tinha tentado anotar no celular e desistido.
O padrão apareceu rápido. Quase todo mundo que tinha tentado tinha desistido. E o motivo nunca era “o app é ruim”. Era sempre uma variação da mesma frase: “dá muito trabalho”.
Depois, baixei os concorrentes. Uma dúzia deles. Instalei, cadastrei, tentei registrar uma venda de um espetinho de R$ 8 como se eu fosse o cara da barraquinha, com fila na frente.
Não consegui em nenhum deles em menos de seis toques.
escolher forma de pagamento → escolher categoria fiscal → confirmar → voltar
Nenhum desses apps é mal feito. Vários são muito bem feitos. O problema é outro: eles foram desenhados para um comércio que tem estoque, fornecedor, contador e CNPJ, e depois foram vendidos para quem tem uma churrasqueira e um isopor. Cada campo daquele existe porque alguém, em algum lugar, precisa dele. Só que a soma de todos os campos que alguém precisa é um labirinto para quem precisa de dois.
Com fila na frente e a carne queimando, seis decisões custam mais caro que um rabisco. Então o rabisco ganha. Sempre ganhou.
O caderno não vence por ser bom. Vence porque a alternativa é pior.
O critério que virou arquitetura
Saí da pesquisa com um número, não com uma lista de features:
Um toque no produto. Um toque em confirmar. Acabou.
Isso parece uma meta de design. Não é. É uma restrição de arquitetura, e ela mata features antes de elas nascerem. Vamos ver o que ela derruba:
- Não tem tela de “nova venda”. Se você precisa abrir a venda antes de fazer a venda, já gastou um toque com burocracia. A tela inicial é a venda.
- Não tem campo de busca. Busca é digitação, digitação é teclado, teclado é a metade da tela sumindo. Os produtos ficam todos numa lista só, visíveis.
- Não tem seletor de quantidade. Tem
[-]e[+]do lado de cada produto, com o número no meio. Três espetinhos são três toques no+, que é exatamente o gesto de quem conta na mão. - Não tem forma de pagamento. Dinheiro, Pix e cartão caem todos no mesmo caixa. O vendedor sabe qual foi, o caderno dele nunca perguntou, e o faturamento do dia é o mesmo nos três casos.
- Não tem fechamento de caixa. O dia vira sozinho. O relógio do aparelho já sabe que dia é hoje; pedir isso ao vendedor é pedir que ele faça o trabalho do computador.
- Não tem cadastro para começar. Você abre e usa. Conta é opcional.
Cada uma dessas ausências foi uma decisão explícita, tomada contra a intuição de “e se alguém precisar?”. Sim, alguém vai precisar. E aí ele usa um dos outros doze apps, que fazem isso muito bem. Este aqui faz uma coisa.
A tela
A tela principal é uma lista.

Cada linha tem o nome do produto em fonte monoespaçada, o preço embaixo, e à direita os três elementos que importam: [-], a quantidade, [+]. A linha inteira muda de cor quando a quantidade passa de zero: fundo verde claro, borda verde, número verde. Você vê o pedido montado sem ler nada.
Quando há algo no pedido, aparece a barra de confirmação com o total. Um toque nela e a venda está registrada, a lista zera, e o contador no cabeçalho sobe.
A lista se reordena sozinha. O produto mais vendido sobe para o topo. Ninguém “caça” um item; o item vai até o dedo. Se o cara vende espetinho de carne, espetinho de frango e refrigerante, e o de carne é 60% das vendas, ele fica na primeira linha por construção, não por configuração.
Isso tem um efeito colateral que eu não tinha previsto e precisei resolver: com 20 ou 30 produtos, a ordenação por popularidade intercala os campeões de venda no meio do alfabeto, e achar um item raro vira rolagem. A solução não foi trocar a ordenação. Foi um botão de ordem alfabética que é estado, não navegação. Ele muda a lista no lugar e a escolha morre no reload.
O cabeçalho conta o dia. Duas linhas: Vendas: 12 em cima, R$ 148,00 embaixo, em corpo grande. É a informação que o vendedor olha de esguelha entre um cliente e outro, e ela está sempre lá, sem precisar abrir nada.
E o dia se fecha sozinho
Não existe botão de fechar o caixa. Tocar no contador abre o resumo do dia, e o dia vira sozinho quando vira. O histórico filtra por hora, dia, semana ou mês, e exporta o período em um toque, direto para a gaveta de compartilhamento do celular. Sem relatório em PDF que ninguém pediu.


O que acontece quando o 4G cai
Na feira, o 4G cai. Em barraca de rua com estrutura de metal, cai mais ainda. Um app de vendas que precisa de rede para registrar uma venda é um app que vai falhar exatamente no dia de maior movimento.
Então a arquitetura é local-first, e isso não é um detalhe de implementação. É a promessa central. Todo o estado operacional mora no próprio aparelho. A venda é gravada localmente, no ato, sem rede. O servidor é sincronização oportunista: quando há conexão, o que está pendente sobe.
Toda a complexidade que sobra dessa escolha, ou seja, o que fazer quando dois aparelhos discordam, foi empurrada para o desenho dos dados, não para a interface. O exemplo mais bonito disso é o fiado.
O caderno de fiado
Vender fiado é o caso mais comum de todos, e o mais mal resolvido por software. O vendedor entrega o produto, não recebe, anota “Seu Zé, 2 espetinhos” numa página do fim do caderno, e acerta na sexta.
A tentação óbvia é criar um campo saldo_devedor no cliente e somar e subtrair. É a tentação errada, e o motivo é sincronização: dois aparelhos do mesmo vendedor vão brigar por aquele campo, e quando brigarem alguém vai perder dinheiro em silêncio.
A escolha foi outra: a dívida nunca é armazenada. Ela é sempre derivada.
- O produto entregue sem pagamento vira uma linha de cobrança, com o preço congelado no momento da entrega.
- O dinheiro que entra na sexta vira uma linha de acerto.
- A dívida é
Σ cobranças − Σ acertos, calculada na hora de mostrar.
Não existe campo de saldo. Então não existe campo para dois aparelhos brigarem. Eles convergem porque as duas listas de eventos convergem, e a conta é a mesma dos dois lados.
E, decisão que parece pequena e não é: a venda fiada nunca entra no faturamento do dia. Ela não é receita quando o produto sai; é receita quando o dinheiro entra. O caixa de terça mostra o que entrou na terça, não o que foi prometido. É exatamente o que o caderno já fazia, e é a única versão que não mente.
Quando o vendedor prepara
O espetinho não sai na hora. O pastel também não. Existe uma categoria inteira de vendedor cujo problema não é registrar, é lembrar de quem é o pedido que está na chapa.
Para eles existe o modo fila, e ele inverte a arquitetura de propósito. Aqui o pedido precisa de um número único entre aparelhos, e precisa ser legível por um cliente que não tem conta nem app. Local-first não resolve isso. Então neste modo, e só neste, o servidor é a fonte da verdade.
O vendedor monta o pedido do jeito de sempre, confirma, e o pedido ganha um número. Ele vê os pedidos em cartões e avança cada um pelos estados: preparando, pronto, entregue, pago.
Os dois caminhos do cliente, e por que eles mostram coisas diferentes
Aqui está a parte que eu acho a mais bem resolvida do produto, e ela nasceu de um problema de segurança.

O vendedor vira a tela
Mostra tudo
Ao criar o pedido, o app gera um QR daquele pedido. O cliente aponta a câmera. Não fala número, não digita, não escuta errado no barulho. O QR carrega o número e um código do banco, então o servidor sabe que quem escaneou estava no balcão: itens, quantidades, total, se já foi pago, e o status.
O cartaz da barraca
Mostra só o status
O QR grande, impresso, colado na banca, gerado pelo próprio app. Leva à página da barraca, onde o cliente digita o número. Funciona, e mostra o status. Não mostra os itens nem o valor. A tela diz: “Para ver os itens, peça o QR do seu pedido no balcão.”
Essa diferença não é uma limitação. É a defesa inteira.


O endereço da barraca é público por construção, já que está impresso num cartaz onde qualquer um pode ver. Se digitar um número bastasse para ver o pedido, qualquer pessoa poderia sentar na praça, testar 1, 2, 3, 4, e ler o que os outros pediram e quanto pagaram. Foi exatamente isso que uma auditoria interna apontou.
A correção óbvia seria dar um código secreto a cada pedido, e o vendedor falaria duas coisas: “pedido 42, código 7391”. O cliente digitaria duas coisas. Numa barraca com fila e barulho, isso dobra o que se fala, dobra o que se digita e dobra o erro.
O código continua existindo, com força total, mas ninguém precisa dizê-lo nem digitá-lo, porque ele viaja dentro do QR. O vendedor vira a tela, que é menos esforço do que falar o número. O cliente aponta a câmera, que é menos esforço do que digitar. E o cartaz continua vivo como plano B, para câmera quebrada ou cliente sem jeito com QR, mostrando só o que é seguro mostrar sem prova de que a pessoa esteve no balcão.
O vínculo do QR é exclusivo. Um pedido reivindicado por um celular não é reivindicável por outro; o segundo recebe um aviso e é orientado a falar com o vendedor. Isso protege o dono do pedido.
O caminho do cartaz não é exclusivo, e isso é deliberado. Duas pessoas da mesma mesa podem acompanhar o mesmo número. Como ali só se vê o status, não há o que proteger.
O faturamento, esse continua local. No instante em que o vendedor marca “pago”, o app cria as linhas de venda no aparelho pelo mesmo caminho de sempre. Duas arquiteturas convivendo, cada uma onde faz sentido, encontrando-se num ponto só.


A decisão de engenharia de que eu mais gosto
O maior obstáculo de qualquer app de vendas não é o uso diário. É o cadastro inicial. Ninguém quer digitar 18 produtos com nome e preço num formulário de celular antes de vender o primeiro espetinho.
Então existem dois modos de cadastrar, e os dois estão no mesmo diálogo, como abas.
Modo manual. Nome e preço, um produto por vez. É o caminho previsível, funciona sem conta, sem internet e sem IA, e continua disponível para sempre. Nada no app depende de ele não ser usado.
Modo texto corrido. Uma caixa de texto onde a pessoa escreve do jeito que fala:
Um modelo de linguagem no servidor transforma isso em catálogo, e o resultado cai numa tela de revisão editável antes de virar cadastro. Nome e preço, linha por linha, prontos para corrigir. A IA propõe; a pessoa decide.

Por que isso é texto, e nunca áudio
Aqui está a decisão que eu levaria para qualquer projeto.
O caminho natural, o que quase todo mundo faz, seria colocar um botão de gravar. A pessoa fala, o app manda o áudio para um modelo, o modelo transcreve, e aí um segundo modelo interpreta o texto. Duas chamadas, duas contas, dois pontos de falha, e um upload de áudio saindo do celular do vendedor num 4G ruim de feira.
Eu não faço nada disso. O campo é uma caixa de texto comum, e a frase que aparece em cima dela diz:
O teclado de todo celular, em toda plataforma, já tem um botão de microfone. Ele transcreve no aparelho, de graça, com o motor de voz do próprio sistema, que é treinado no idioma do usuário e já entende o sotaque dele.
O ganho é em quatro frentes ao mesmo tempo:
- Custo. Uma chamada em vez de duas, e sem tokens de áudio, que são os caros.
- Rede. Sobe texto, não áudio. Numa feira com 4G ruim, isso é a diferença entre funcionar e não funcionar.
- Privacidade. A voz do vendedor nunca sai do aparelho dele.
- Superfície de código. Não existe upload de mídia, nem formato de áudio, nem permissão de microfone para pedir, nem gravação interrompida para tratar. Tudo isso é código que eu não escrevi e não preciso manter.
E o custo é zero, porque para quem prefere digitar, digitar continua sendo o caminho. É o mesmo campo. Não há dois fluxos, há um só, e o usuário escolhe como preenchê-lo sem que o app precise saber.
O portão que roda antes da IA
Tem mais uma peça, e ela é barata e cortante. Antes de gastar uma chamada ao modelo, uma expressão regular pergunta se aquele texto tem cara de cardápio: existe pelo menos uma palavra de duas letras, e existe pelo menos um número, seja em dígito ou por extenso.
Ela conhece os numerais por extenso do português inteiro, de “um” a “novecentos”, com “meia”, “meio”, “mil” e as grafias alternativas como “catorze” e “cincoenta”. Porque quem dita fala por extenso e quem digita não, e os dois têm que funcionar.
Se o texto não passa nesse portão, a pessoa recebe um aviso na hora dizendo o que faltou, e nenhuma chamada é feita. Antes disso o app dizia “A IA falhou ao processar seu texto”, que é mentira: a IA não tinha nem rodado.


Duas medições ficaram no código, porque este é o tipo de coisa que volta a morder:
- A expressão tem dois lookaheads que começam com
.*, então o tempo cresce ao quadrado do tamanho da entrada. Medi com a pior entrada que consegui construir: 1,77 ms em 1000 caracteres. É seguro porque 1000 é o teto, imposto nos dois lados, no cliente e no servidor. Subir esse teto custa quadrático, e o comentário no código diz isso para quem for mexer. - O portão erra para o lado permissivo de propósito. “Cento de salgado” passa sem preço nenhum, e passar custa um dos usos diários. Aceito, porque o erro contrário, recusar um preço legítimo, é o bug que já voltou três vezes.
Cor medida, não escolhida
Aqui é onde o projeto virou outra coisa.
O app tem tema claro e escuro, e um sistema de tokens. Nenhum componente conhece uma cor; todos conhecem um papel: surface, fg, success, danger, warning. A troca de tema não passa por nenhum if no código de tela.
Isso já é comum. O que não é comum é o que veio depois: eu parei de escolher as cores e passei a medi-las.


O verde da minha logo é #00E175. É bonito. Sobre branco, ele dá 1.75:1 de contraste, e o mínimo legível é 4.5:1. Ou seja: aquele verde nunca pode ser texto. Ele preenche; a tinta é uma versão escurecida dele, no mesmo matiz. (Esta página segue a mesma regra: o verde aparece em toda parte e nunca como texto.)
O azul da marca não podia ser o azul dos botões, e o motivo é aritmético, não estético. Para o texto azul passar no critério de acessibilidade em cima do próprio tinte, o tinte precisaria ser claro demais; para o tinte se ver sobre o fundo do app, precisaria ser escuro demais. A interseção é vazia. Não existe. Então há dois azuis:
| #004DFF | #2466FF | |
|---|---|---|
| texto branco em cima | 5.99 | 4.75 |
| sobre o fundo claro | 4.97 | 3.94 |
| sobre o fundo escuro | 3.10 | 3.90 |
| sobre o painel de modal escuro | 2.03 | 2.55 |
O forte fica na faixa do cabeçalho, que é superfície. O mais claro vira o azul de todo botão: perde um pouco no tema claro e melhora justamente onde o outro era mais fraco.
Os botões são “teclas”: material sólido com uma aresta escura embaixo, que dá relevo físico. A regra é uma linha:
Aresta de outra família lê como duas peças coladas, não como uma tecla. E o que se iguala entre os botões é a razão de relevo (~2.20:1), não o multiplicador: o mesmo ⅔ dá 2.20:1 no verde e só 1.70:1 no azul, porque a face azul parte de uma claridade menor.
Eu queria uma cor própria para o fiado. Fui medir. Todo âmbar que passa no critério de contraste nos dois temas cai a 8 ou 15 graus de matiz do amarelo de “atenção” que já existia, perto demais para o olho distinguir.
Não existe amarelo de fiado. E, pensando bem, nem precisa: atenção e fiado são a mesma coisa. Os dois são pendência.
E tudo isso é um teste que roda. Ele lê o CSS real e reprova o build se um matiz sair da família, se um texto não passar no contraste sobre a superfície em que ele de fato pousa, ou se a rampa de elevação do tema escuro inverter. Não é um documento dizendo como deveria ser. É um portão.
Ele existe porque a alternativa falhou: uma superfície ficou em matiz 43 (marrom) no tema escuro enquanto o resto da paleta vivia entre 213 e 222 (azul-ardósia), pintando o cartão da lista de produtos de uma cor que não pertencia ao sistema. Passou por tudo. tsc não lê string. eslint não lê string. O build gerou a regra sem reclamar.
A fonte, e a lição que ela deu
O app usa duas fontes próprias, auto-hospedadas: Google Sans Flex para a interface e Geist Mono para os nomes de produto e os endereços do QR. Os arquivos vivem no repositório, em woff2, fatiados por faixa de caracteres, com licença e procedência registradas ao lado. Auto-hospedar não é purismo: é para o vendedor não depender de um terceiro no meio da feira.
Antes disso o app não escolhia fonte nenhuma. Valia o padrão de cada sistema, o que dá San Francisco no iPhone, Roboto no Android e uma terceira coisa no servidor de integração. O mesmo desenho medindo três coisas diferentes.
E aí veio a parte que eu não esperava. Depois de a fonte entrar no repositório, dois testes de geometria continuavam falhando no servidor e passando na minha máquina. A hipótese óbvia era que a fonte não estava carregando lá. Medi:
minha máquina servidor largura do texto 105.375 105.375 ← idêntica: a fonte carregou largura dos botões 169.172 174.766 ← +5,6px, e é daqui que vem
A fonte tinha chegado. A largura não. O motor de rasterização do Linux aplica um ajuste de contorno diferente do da minha máquina, e o mesmo arquivo produz medidas diferentes.
O que fechou foi pedir ao navegador a métrica geométrica pura, sem ajuste do sistema operacional. Depois disso, 69.375 contra 69.375. E teve um segundo capítulo: a primeira correção aplicou isso só na raiz do documento, e o botão não herdava. Era justamente o botão que inchava.
Outros detalhes que viraram teste em vez de intenção
- Todo campo de texto tem no mínimo 16px. Abaixo disso, o Safari do iPhone dá zoom sozinho ao focar, e a tela pula. Um scanner varre o JSX e reprova qualquer campo menor.
- Todo diálogo é um diálogo de verdade: se anuncia como tal para leitores de tela, fecha no Escape, prende o foco dentro dele e trava a rolagem de trás. Antes disso havia 16 overlays montados à mão em 14 arquivos, e nenhum fazia nada disso.
- O nome acessível contém o texto visível (WCAG 2.5.3). Para quem controla o app por voz, o que está escrito no botão tem que ser o que funciona quando você fala.
- O
confirm()do navegador foi embora, nas nove chamadas que existiam, e virou um diálogo do próprio app, com a mesma trava de foco de todos os outros.
O som que nunca mente
O app toca um som quando a venda é registrada. Parece detalhe. É a regra mais rígida do código inteiro:
Dos seis pontos que tocam som, quatro vão à rede e podem falhar. Se a chamada estivesse no onClick do botão, com 4G ruim na feira o vendedor ouviria “registrado”, o pedido falharia, e ele chamaria o próximo cliente. O app teria mentido para ele no único momento em que ele confiava no app sem olhar.
Então a chamada mora sempre depois do await, no ramo que já sabe o resultado. E existe um teste que lê o código-fonte e reprova qualquer som que apareça num onClick.
Tem um corolário que eu gosto ainda mais. Quando o vendedor toca duas vezes em confirmar, o que acontece o tempo todo, com a mão suja e pressa, o segundo toque não toca som nenhum. Não houve venda para anunciar. Um “confirmado” ali seria a mesma mentira, só que na direção contrária.
E o som é sempre redundância, nunca a única confirmação. No iPhone com o silencioso ligado ele simplesmente não existe, e o Safari não implementa vibração em versão nenhuma. Existem contornos conhecidos para forçar o som mesmo assim. Nenhum deles entrou: respeitar o interruptor físico é o contrato que o dono do aparelho espera.
Como eu sei que funciona
Aqui está a parte de que eu mais me orgulho, e é onde o projeto deixou de ser um app e virou um exercício de engenharia.
Comecei com o de sempre: TypeScript, ESLint, testes unitários. Verde em tudo. E então, numa revisão, encontrei nove defeitos, e nenhum dos nove era pego pela suíte que eu tinha. Todos os nove já estavam no ar.
O diagnóstico foi desconfortável e útil: eu tinha quatro camadas de gente (e de máquina) lendo código, e nenhuma executando comportamento. Verificação de forma pega erro de forma. Toda a classe de defeito que é sequência, ou seja, estado que aparece na ordem errada, foco que vai para o lugar errado, ramo que não renderiza, passa por typecheck, lint, revisão e implementação sem tocar em nada.
Então hoje são quatro camadas, e a escolha entre elas não é gosto:
| Camada | O que ela consegue provar |
|---|---|
| Lógica pura e forma do código | A regra está certa; o arquivo declara X e o chamador usa Y |
| Runtime (DOM real) | O que renderiza em qual estado, para onde o foco vai, o que entra na árvore depois do commit |
| Geometria (Chromium) | Para que lado o painel se move, o que o CSS resolve, o que vira bloco contentor |
| Banco (SQL real) | As regras de dados valem sob as permissões de um usuário comum |
E o comando que roda a maior parte disso leva 14 segundos. Isso é o número que importa: uma verificação que demora dois minutos é uma verificação que você deixa de rodar.
# camada 1: lógica e forma do código $ npm test ℹ tests 392 ℹ pass 392 ℹ fail 0 ℹ duration_ms 1271.589542 # camada 2: comportamento, em DOM real $ npm run test:runtime ℹ tests 110 ℹ pass 110 ℹ fail 0 ℹ duration_ms 3035.397875 # camada 3: geometria, em Chromium $ npm run test:visual 41 passed (48.0s) # o que roda sozinho a cada turno $ time npm run verify real 14.59 user 40.61 sys 4.29
Ela roda sozinha. Um hook dispara a verificação ao fim de cada turno de trabalho em que algum arquivo de código mudou, e bloqueia o encerramento se falhar. Regra escrita pede; hook executa. A diferença entre as duas é a diferença entre um processo e um bilhete na geladeira.
O exemplo que justifica a quarta camada
Eu quase não construí a camada de geometria. Parecia caro demais subir um Chromium de verdade para testar CSS. Aqui está o caso que me convenceu, e ele é literal.
Existe uma regra no painel dos modais: transform: none. Troque por transform: scale(1).
Isso passa no typecheck. Passa no lint. Passa nas duas primeiras camadas de teste. Passa até no teste que roda o Tailwind de verdade. E é invisível em qualquer inspeção, porque matrix(1, 0, 0, 1, 0, 0) é a matriz identidade, ou seja, o elemento não se move um pixel.
Só que qualquer transform diferente de none faz o elemento virar bloco contentor dos descendentes posicionados de forma fixa. E o calendário que abre dentro do modal de pagamento, que deveria ocupar a largura da tela, encolhe para a largura do painel.
Nenhuma releitura de código pega isso. Só o navegador sabe.
A regra que mudou como eu escrevo teste
Toda correção ganha um teste. Isso é padrão. O que não é padrão é a segunda metade:
Sem esse passo você não sabe se o teste cobre o bug ou se ele só passeia perto dele. E isso não é teoria: eu escrevi um teste de diálogo aninhado que passava com e sem a correção. Ele foi removido, e a nota do porquê ficou no arquivo.
Um teste que nunca pôde ficar vermelho é pior que nenhum teste. Ele consome a atenção que o verde deveria merecer.
O mesmo raciocínio pegou uma armadilha na camada de geometria. Um test.only esquecido faz a suíte inteira virar “1 passed”: verde, bonito, com sete testes pulados e nenhuma linha de aviso. Hoje isso é erro fatal no servidor de integração, nomeando o teste culpado. Verde mentiroso é o pior estado possível de um projeto, porque ele é indistinguível do verde verdadeiro exatamente até o dia em que não é.
O bug de que eu mais gosto
Uma pergunta simples: quanto vale o carrinho que está montado agora?
Cinco lugares do app respondiam isso. E davam três respostas diferentes para o mesmo carrinho, no mesmo instante, quando um produto do carrinho tinha sido apagado do cadastro no meio do caminho. Um zerava o item. Outro usava o preço congelado. Outro descartava.
Com desconto: o vendedor cobrava R$ 2 → o caixa registrava R$ 14
Esse é o tipo de defeito que não cabe em nenhum diff. Nenhum dos cinco arquivos está errado sozinho. A contradição só existe entre eles. Revisão olha diff, então é estruturalmente cega para isso, e olhar mais vezes não ajuda.
A correção óbvia seria uma função única que os cinco passassem a chamar. Não foi essa. A correção que entrou é menor e ataca a causa: as duas portas que tiram um produto do cadastro passaram a limpar o carrinho junto, estabelecendo o invariante “todo item do carrinho existe no cadastro”. Com ele, os cinco leitores ficam corretos por construção.
Guarde essa frase. Ela volta na próxima seção, e da segunda vez o assunto não é dinheiro na tela. É quem você é.
Sem a planta baixa
Não vou detalhar as defesas, porque dizer exatamente onde estão as fechaduras não ajuda ninguém a dormir melhor. Mas a postura vale, e o processo vale mais ainda.
O que se sustentou sob teste: política de conteúdo sem execução de script inline, transporte estrito com pré-carga, listas de origem permitida que falham fechado e são separadas por função (porque o raio de dano de cada uma é diferente), dependências sem alerta conhecido, isolamento por linha ativado e forçado em todas as tabelas, nenhuma permissão concedida a visitante anônimo, e toda função de banco com o caminho de busca fixado.
Mas o achado que importa é outro, e ele é a mesma lição do carrinho, aplicada em cima de identidade.
Uma auditoria anterior tinha corrigido dois deles. O terceiro era justamente o que escreve o vínculo, e ninguém tinha percebido, porque cada um dos três, lido sozinho, parece certo. A contradição só existe entre eles. É literalmente o bug do carrinho, com outro nome e outro estrago possível.
A correção é a mesma forma: uma função passa a ser a dona única do critério, e os três consumidores passam a chamá-la. O critério é o que o provedor de login verificou de fato, nunca um campo que o usuário controla. E o resultado foi medido depois, com o ataque rodado contra o banco real: ele agora não grava nada e não concede nada.
Na mesma leva, as ações administrativas que faltavam (conceder administrador, ativar conta, lançar pagamento) passaram a deixar rastro numa trilha que só aceita inserção. Não dá para apagar o próprio caminho.
A parte mais útil: a revisão do próprio conserto
Depois de corrigir, eu revisei a correção. Ela tinha oito defeitos.
E aqui está o dado que eu levo para o resto da carreira: nenhum dos oito foi encontrado lendo o código. Todos saíram de comando executado, e três deles vieram da ferramenta de análise da plataforma e da própria suíte falhando, não de olho humano. Alguns exemplos:
- A nova dona do critério falhava aberto quando o campo vinha vazio, porque em SQL uma string vazia se compara igual e um nulo fecha a comparação. Achado por uma prova em transação com rollback.
- A migração dizia “não muda comportamento” e mudava: um
limit 1esquecido estreitou “existe alguma identidade verificada que afirma este e-mail” para “a mais recente afirma”. Isso trancaria o primeiro login de alguém, em silêncio. - Uma instrução que atualizava dois campos de poder de uma vez gravava só o primeiro evento na auditoria.
- Uma nova função interna nasceu com permissão de execução para visitante anônimo, e a prova de permissões que eu tinha escrito excluía justamente aquele tipo de objeto por filtro. Quem viu foi a ferramenta da plataforma.
Esse último é o mais instrutivo, porque ele apareceu por causa da correção anterior. Consertar cria superfície nova. A lição que ficou escrita no repositório é curta: a suíte não substitui o analisador da plataforma, porque os dois olham coisas diferentes.
O repositório guarda 13 auditorias, e a última se chama, literalmente, “os oito defeitos que a review achou no próprio conserto”. Não é autodepreciação. É o registro de que o processo funcionou.
A documentação que não apodrece
O projeto tem 72 documentos e cerca de 45 mil linhas de prosa: especificações de design, planos de implementação, auditorias. E uma regra que mudou tudo:
Documento é permanente; o mundo não é. “A consulta devolve 19” vira 18 no dia seguinte. “O arquivo tem 909 linhas” vira 919. Um número de linha citado numa doc apodrece na primeira edição alheia e passa a apontar para outra coisa com cara de certo, que é o pior tipo de errado.
Então as specs não trazem a resposta, trazem a query, o grep, o comando que a reencontra. Uma referência móvel vira um identificador imutável. Isso converte a taxa de decaimento da documentação em zero, em vez de depender de alguém lembrar de reconferir.
O corolário incomoda e é verdadeiro: prosa é afirmação sobre o código, e é onde a afirmação errada sobrevive, porque nada a compila. Typecheck, lint, teste e build passam todos verdes com a documentação mentindo.
O código segue a mesma disciplina. Os comentários deste projeto quase nunca dizem o que o código faz. Dizem por que ele é assim, o que foi tentado antes, e o que quebrou. Um exemplo real, sobre por que um componente de 12 linhas existe separado:
Isso não é comentário. É a prova de por que a alternativa mais simples não serve.
Há até um plano no repositório cujo cabeçalho avisa: “o executado difere do escrito, leia isto antes”, e lista as três coisas que a execução desmentiu. Plano não é profecia. É registro datado do que se pensava, e a execução tem o direito de contradizê-lo, desde que a contradição fique escrita.
O que os números dizem
O último número é o que fecha o círculo. 210 KB. É menos que uma foto de celular. Um app que precisa funcionar num aparelho barato, com 4G ruim, numa calçada, não pode custar quatro megabytes para abrir.
O que eu levo daqui
- A restrição certa mata mais features do que qualquer roadmap. “Dois toques” não foi uma meta de usabilidade, foi um filtro. Toda vez que apareceu uma ideia boa, a pergunta não foi “isso é útil?”, foi “isso cabe em dois toques?”. A maioria das ideias boas não cabia. Elas continuam boas e continuam de fora.
- A melhor solução de IA costuma ser a que usa menos IA. O caminho óbvio seria mandar áudio para um modelo e pagar a transcrição. O caminho certo foi perceber que o teclado do celular já faz isso de graça, melhor, e sem tirar a voz do usuário do aparelho. Sobrou para a IA exatamente a parte que só ela resolve. Antes de escolher um modelo, vale perguntar quanto do problema já está resolvido por algo que o usuário tem no bolso.
- Medir é diferente de escolher. Metade das minhas decisões estéticas foram derrubadas por uma conta de contraste, e o resultado ficou melhor do que a minha intenção original. Inclusive quando eu perdi a discussão para a régua. A fonte deu a mesma lição em outro eixo: carregar o mesmo arquivo não garante a mesma largura.
- Ler código não é verificar código. Ler diz o que o código diz; só executar diz o que ele faz. A pergunta a fazer cedo em qualquer projeto é: qual comando faz o software rodar e falha sozinho quando o comportamento quebra? Se a resposta for “typecheck e lint”, não existe verificação de comportamento em lugar nenhum. Existe leitura em quatro camadas.
- Toda pergunta importante precisa de um dono nomeado. Isso apareceu duas vezes no mesmo projeto: “quanto vale o carrinho” respondido por cinco lugares, e “quem é esta sessão” respondido por três. Nos dois casos, nenhum arquivo estava errado sozinho, e nos dois casos a contradição custava caro.
- Verde precisa ser confiável, senão é pior que vermelho. Um teste que nunca pôde falhar, uma suíte que reporta “1 passed” com sete testes pulados, um documento que afirma o que era verdade há três meses: todos os três são a mesma doença. Eles gastam a sua confiança sem entregar nada em troca.
- O papel é um concorrente sério, e merece respeito. Ele não tem tela de carregamento, não pede login, não atualiza sozinho no meio do expediente e funciona com 0% de bateria. Qualquer software que pretenda substituí-lo tem que ganhar em atrito, não em recursos. Perder para o caderno com um app cheio de features não é azar. É ter competido na dimensão errada.
O cara da barraquinha ainda usa o caderno dele, e está tudo certo. Eu não construí isso para convencê-lo. Construí para descobrir se dava. Dá. Duas batidas de dedo, o dia fecha sozinho, e quando a rede cai ninguém percebe.
O resto, os 502 testes, as 73 migrações, a paleta medida em contraste, existe só para que essas duas batidas nunca falhem.