0043 — Ninguém consegue dizer que versão está no ar, nem o que mudou nela¶
| Status | CONCLUÍDA |
| Branch | spec/0043-versao-e-novidades |
| Criada em | 2026-08-22 |
| Apps afetados | api · web · e2e |
| Revisão do problema | aprovada na rodada 2 |
| Revisão da experiência | aprovada na rodada 3 |
| Revisão do detalhamento | aprovada na rodada 9 |
| Revisão da implementação | aprovada na rodada 9 (branch) |
Status:
RASCUNHO→APROVADA→EM IMPLEMENTAÇÃO→CONCLUÍDA
A spec tem três partes, escritas em ordem, e quatro portões — cada um revisado por um subagente de contexto limpo, em até 3 rodadas. A máquina de revisão é do plugin superpowers; a régua é a rubrica correspondente em .claude/rubrics/. Achados vêm classificados: qualquer Critical reprova, Important em aberto reprova salvo decisão registrada do usuário, e Minor vira pendência aceita.
- A Parte I diz qual é o problema e como se convive com ele hoje. Revisada na seção 6 (necessidade × jornada de hoje). É o portão mais barato de todos, e o único que pergunta contra que problema isto está sendo construído.
- A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
- A Parte III diz como se constrói. Revisada na seção 16 (segurança × clareza técnica) — autorização, isolamento, e se sobrou decisão para quem vai codar.
- A implementação é medida contra as três, na seção 17 (aderência × qualidade × testes) — uma revisão por fase, e uma da branch inteira antes de fechar.
Cada parte só se escreve depois que a anterior passou. Descrever tela sobre problema mal enquadrado, ou detalhar endpoint sobre experiência vaga, é o jeito mais caro de descobrir que era outra coisa.
Parte I — O problema¶
Aqui não entra solução: nem tela, nem endpoint, nem botão. Só o que dói, para quem, e o que se faz hoje. Se você já sabe qual é a tela, guarde para a Parte II.
1. Dor¶
A pergunta "que versão está no ar?" não tem resposta dentro do produto. Hoje ela só se responde por fora, com acesso SSH à máquina de produção — e quem não tem esse acesso não responde de jeito nenhum.
Isso custa em três momentos distintos. Dois já aconteceram e têm registro; o terceiro é um estado, não um evento — e a distinção importa, porque uma Parte I que se apoia em fatos conferíveis não pode alegar ocorrência que não tem lastro:
Quando alguém relata um defeito. A primeira pergunta útil é "isso já foi corrigido no ambiente em que você está?", e ela não é respondível: não há como comparar o que a pessoa vê com o que o repositório tem. Em 2026-08-22 um cliente foi ativado em produção e travou numa tela de recusa de acesso. A correção não existia ainda — nasceu no mesmo dia, no fix/0012 —, e a pergunta apareceu depois dela pronta: "já está no ar aí?". Respondê-la exigiu abrir um terminal na box.
Se um deploy der errado — e é aqui que a dor é potencial, não observada. Nenhum deploy precisou voltar até hoje, e não há registro de rollback em spec/ nem em fix/. O que existe é a ausência do caminho de volta: não há marca nenhuma no repositório dizendo "isto foi para produção" — git tag devolve vazio —, e o procedimento de rollback do próprio /ship está escrito como "git reset --hard <commit-anterior>", com o commit anterior a cargo de quem lembrar. A hora de descobrir que isso não basta é a pior possível.
Quando algo muda para quem usa. A spec 0041 refez a tela de Equipe inteira — cadastro que não funcionava, lista que não filtrava, telas que não cabiam no celular — e ninguém foi avisado de nada. Quem usa o produto descobre a mudança esbarrando nela. Não há canal: nem dentro do app, nem fora.
Não há volume a medir. A produção tem um cliente, ativado hoje; o dev-remote é do time. O alcance desta dor não se sustenta por número e sim pelo desenho: toda pergunta sobre versão, de qualquer pessoa, depende hoje de acesso à infraestrutura.
2. Quem sente¶
Quem deploya, e é quem sente primeiro. É caminho obrigatório: todo /ship termina sem deixar marco, e todo diagnóstico de "está no ar ou não" começa por SSH.
Quem dá suporte — hoje a mesma pessoa. Sem versão, um relato de defeito não tem referência temporal: não dá para separar "isso é um bug novo" de "isso é um bug já corrigido que ainda não chegou aí".
Quem usa o produto, em todos os papéis — ADMIN_TENANT, ANALYST, OPERATOR, VIEWER e o AGENCY_USER da imobiliária. Não é a falta da versão que os atinge, e sim a do aviso do que mudou.
O que já aconteceu é a metade do time; a outra metade é prospectiva, e o registro não finge o contrário. A 0041 mudou uma tela inteira sem que existisse canal para contar — isso é fato, e é o que a seção 1 registra. O que ainda não aconteceu é alguém de fora esbarrar nessa mudança: o primeiro cliente foi ativado hoje, e até agora quem usou o produto foi o time, que acompanha o repositório. As duas frases convivem porque falam de coisas diferentes: a ausência do canal é presente, o esbarrão é futuro. É a dor mais silenciosa das três, e a única que alcança gente fora do time.
Quem não sente: ninguém está bloqueado. O produto funciona sem isto — o que não existe é a resposta.
3. A jornada de hoje¶
O gatilho é sempre uma pergunta, e ela chega de três formas.
"O que está em produção agora?"¶
O caminho, ponta a ponta, é o que foi percorrido nesta sessão em 2026-08-22:
ssh root@179.198.118.166egit -C /opt/garantora rev-parse --short HEAD— devolve um SHA de sete caracteres,c8f9649.- Comparar com
git rev-parse --short origin/mainna máquina local. - Se forem iguais, a produção está atualizada. Se não, abrir
git logpara ver o que falta.
Onde quebra: no passo 1. Ele exige credencial de SSH da produção, que só quem opera a infraestrutura tem. Para todo o resto — cliente, suporte, qualquer pessoa do time sem acesso à box — a pergunta simplesmente não tem caminho. E um SHA não responde "isso é mais novo ou mais velho que o que eu tenho aqui?" sem uma terceira consulta.
"Esse defeito que você está vendo já foi corrigido?"¶
- Reproduzir o relato, ou pedir mais detalhe.
- Procurar em
fix/espec/se existe registro daquilo. - Achando, descobrir em que commit entrou (
git log --oneline -- fix/NNNN-*). - Voltar ao passo do fluxo anterior para saber se aquele commit está no ambiente da pessoa.
Onde quebra: o passo 4 depende inteiramente do primeiro fluxo, com o mesmo bloqueio. E os passos 2 e 3 exigem conhecer a numeração interna de spec e fix, que é vocabulário do time.
"O que mudou?"¶
Não há caminho para o conteúdo — mas há o momento, e ele já está no ar vazio.
O PwaUpdateBanner (spec 0005) está montado no layout raiz (apps/web/src/App.tsx:460) e mostra a frase "Nova versão disponível.", com um botão de atualizar, a todo usuário que receber uma atualização — ele só renderiza quando o service worker acha versão nova. É hoje o único instante em que o produto fala de si mesmo — e ele diz que há algo novo sem dizer o que. A pessoa clica, a página recarrega, e ela volta ao trabalho sem ter aprendido nada.
E o produto exibe uma versão, que está errada. apps/api/app/main.py:484 declara version="0.1.0" no FastAPI — visível no Swagger — e apps/web/package.json:3 repete o mesmo 0.1.0. Os dois estão congelados desde o início do projeto, atravessaram 41 specs e 12 fixes, e nunca foram tocados. Isso é pior que ausência: quem perguntar recebe uma resposta, e ela não é verdade.
O resto da informação — o que de fato entrou em cada deploy — existe nas mensagens de commit e nos registros de spec/ e fix/, mas vive no repositório. Quem usa não tem por onde começar.
O que já se tentou¶
A spec 0042 tentou, há um dia, e a tentativa está pela metade. Ela pôs um campo commit no GET /api/v1/health, com um Settings.GIT_COMMIT injetado no build por ARG/ENV, e o comentário do código diz por que: "existe para responder de fora 'o ambiente está servindo qual versão?' — pergunta que antes se respondia por aproximação, comparando a data de criação da imagem com a do último commit".
Em produção, hoje, aquele endpoint responde "commit": "desconhecido". O docker-compose espera GIT_COMMIT do ambiente (${GIT_COMMIT:-desconhecido}) e o deploy.sh não o passa, então o valor nunca chega. A tentativa mais recente de responder a esta pergunta está no ar e não funciona.
Duas coisas se aprendem com isso, e as duas são desta spec: um mecanismo de versão que depende de alguém lembrar de passar um argumento no build vai ficar em branco; e um SHA de commit, ainda que chegasse, não é uma versão — não ordena, não agrupa, e não diz nada a quem não lê git log.
4. Resultado esperado¶
Passa a ser possível responder "que versão é esta?" de dentro do produto, sem terminal e sem acesso à infraestrutura, por qualquer pessoa que consiga entrar.
E passa a ser possível saber o que mudou sem conhecer a numeração interna de spec e fix.
Como se sabe que resolveu, e tudo isto é conferível sem métrica instrumentada:
- Abrir o produto e ler a versão, sem SSH — em produção e no dev-remote, onde ela vem marcada como à frente da última publicada (ver 7.1).
- Cada deploy deixa uma marca recuperável sem ler o
git log, e o rollback passa a citar algo que uma pessoa consegue nomear, em vez de um SHA que ela precisa achar. - A versão que o produto exibe passa a ser a verdadeira — hoje ele responde
0.1.0desde sempre, e responder errado é pior que não responder. - Saber a versão de um ambiente deixa de depender de alguém lembrar de um passo no build.
- Quem entrou depois de um deploy consegue ver o que entrou, escrito em português de produto.
- A pergunta "isso já foi corrigido aí?" se responde comparando duas versões, não dois SHAs.
5. Fora de escopo¶
- Changelog público fora do app — página de marketing, RSS, e-mail de release. O canal desta spec é dentro do produto, para quem já entrou.
- Versionamento da API (
/api/v2, cabeçalho de versão, contrato negociado). É outro assunto, e oCLAUDE.mdregistra que hoje não há base instalada a preservar: o único cliente do contrato é oapps/web, versionado junto. - Nota de versão por cliente ou por papel. Todo mundo lê a mesma nota. Recortar o texto por quem lê é problema que ainda não existe — há um cliente.
- Rollback automático. O que esta spec muda é o rollback passar a ter algo nomeável para citar; executá-lo continua manual, como o
/shipjá descreve. - Histórico de versões navegável com filtro e busca. O que a Parte II vai desenhar é o acesso ao que mudou; uma tela de arqueologia de releases é outra feature.
- Numerar retroativamente o que já foi ao ar. As 41 specs e os 12 fixes anteriores não ganham versão. A contagem começa agora.
6. Revisão do problema¶
Preenchido pelo laço do /spec new, rubrica .claude/rubrics/problema.md. Registrar todas as rodadas, inclusive as reprovadas.
| Rodada | Critical | Important | Minor | Veredicto |
|---|---|---|---|---|
| 1 | 0 | 2 | 3 | Reprovada |
| 2 | 0 | 0 | 5 | Aprovada |
Rodada 1 — o que foi apontado e o que se fez. Os dois Important eram sobre a jornada de hoje afirmar demais e olhar de menos.
O primeiro: a Parte I dizia que "o produto não fala sobre si mesmo em lugar nenhum", e isso é falso. O PwaUpdateBanner da spec 0005 está montado no layout raiz e mostra a todo usuário "Nova versão disponível." — o momento do aviso já existe, vazio de conteúdo. E o produto exibe uma versão: 0.1.0 no FastAPI e no package.json, congelada desde o início e visível no Swagger, o que é pior que ausência porque responde errado. Incorporados, os dois deixam a Parte I mais forte: mostram que a casa da versão e o instante do aviso já estão construídos e vazios.
O segundo: "os três já aconteceram" não tinha lastro para o caso do deploy que dá errado — nenhum precisou voltar, e não há registro de rollback. Virou o que de fato é: um estado, a ausência do caminho de volta, e não um evento.
Dos Minor: dois dos resultados esperados escolhiam o mecanismo (tag e endpoint) antes da Parte III e viraram observáveis; a dor de quem usa o produto era prospectiva escrita no presente e passou a dizer isso; e a contagem de specs anteriores era 41, não 42.
Rodada 2 — aprovada, com cinco Minor, todos fechados. Três eram tensões internas que a Parte II herdaria: a dor dizia que a pergunta é feita "o tempo todo" e quatro parágrafos abaixo declarava que não há volume a medir; a seção 1 contava a dor de quem usa como acontecida e a seção 2 a chamava de prospectiva, sem dizer que falam de coisas diferentes (a ausência do canal é presente, o esbarrão de alguém de fora é futuro); e o caso do cliente que travou comprimia a ordem dos fatos — a correção não existia quando ele travou, nasceu no mesmo dia, e a pergunta que precisou do terminal veio depois dela pronta.
Os outros dois: a seção 5 ainda nomeava a tag como mecanismo dentro da Parte I, e o PwaUpdateBanner não aparece para todo usuário, e sim para todo usuário que receba uma atualização — a conclusão que a spec tira dele não muda.
Resultado: aprovada na rodada 2.
Pendências aceitas: o que a revisão ainda apontou e se decidiu não resolver, com o porquê. Vazio se não houver.
Parte II — A experiência¶
Não preencher antes da revisão da seção 6 estar aprovada.
7. Jornada pretendida¶
Há duas jornadas, e elas não se cruzam. Uma é de quem publica, acontece no terminal e termina numa tag; a outra é de quem usa, acontece na tela e termina numa leitura. A primeira produz o insumo da segunda, e é por isso que a nota de versão não pode ser "escrita depois": ou ela sai no deploy, ou não sai.
Jornada A — quem publica¶
O ponto de entrada não muda: /ship, o mesmo comando de hoje. O que muda é o que acontece entre o pré-voo e o gate humano.
- O pré-voo mostra o que já mostra — commits que entram e migration pendente — e mais duas linhas: a versão que está no ar hoje, e a que ele propõe.
- A proposta vem com o motivo à vista: "1.5.0, porque entrou uma spec". Entrou só fix ou tweak, propõe patch; entrou spec, propõe minor; major nunca é proposto — quem sobe major é você, digitando. A versão se decide aqui, na primeira passada, antes de qualquer arquivo existir: o nome do arquivo é a versão, e mudá-la depois deixaria uma nota órfã no disco com o número errado.
- Junto vem o texto do que entrou, montado dos commits e dos registros de
spec/,fix/etweakque entraram desde a última versão. Ele é rascunho, não decisão. - O rascunho vira arquivo no repositório, e o
/shippara ali. Ele grava a nota da versão proposta e diz que gravou; você abre no editor de sempre, ajusta o que quiser, e roda o/shipde novo. - Na segunda passada o comando mostra o texto por inteiro — não resumido — e pede a confirmação. Ele não regrava o arquivo: achando a nota da versão proposta já no disco, usa o que está lá. Regravar por cima apagaria exatamente a revisão que as duas passadas existem para permitir.
- O arquivo precisa estar commitado e empurrado antes do deploy — o
/shipdeployaorigin/maine já recusa hoje quando o main local está à frente. Não é passo novo, é o passo de sempre; o que muda é que agora há um arquivo a mais para levar junto. - Confirmado, o deploy segue como hoje. Ao fim, além do que já faz, ele marca a versão no repositório e a nota passa a existir dentro do produto.
Por que dois passos, e não um prompt. A nota é o conteúdo inteiro desta feature, e escrever texto de produto dentro de um prompt de terminal é a pior superfície possível: sem quebra de linha confortável, sem desfazer, sem revisão. Como arquivo, ela é revisável como qualquer outro — entra no diff, sobrevive a um /ship abortado, e fica versionada junto do código que descreve.
Onde isso muda o deploy de hoje: o gate humano deixa de ser só "pode subir?" e passa a ser "pode subir como esta versão, com este texto?". É uma pergunta a mais, no mesmo ponto, e é a única forma de a nota sair no momento em que alguém ainda lembra do que fez.
E se o texto estiver vazio ou o deploy for de uma coisa só? O /ship não inventa: se não houver commit desde a última versão, ele já recusa hoje. Se houver commits e o texto sair pobre, o rascunho é ruim e você reescreve — o comando não tem opinião sobre qualidade.
Jornada B — quem usa¶
O ponto de entrada é novo, e é um ícone na barra, ao lado dos que já existem.
- A pessoa entra no produto e trabalha normalmente.
- Tendo entrado versão nova desde a última vez que ela olhou, o ícone traz uma marca de não lido. Sem novidade, ele é só um ícone.
"Ela" é a pessoa, não o navegador. Quem leu no computador do escritório e abre o celular em visita não vê a marca de novo: o que ficou lido ficou lido, em qualquer aparelho, e limpar o navegador não ressuscita o aviso. É o oposto de barato de construir, e é o que impede a marca de virar ruído para quem trabalha em dois lugares. 3. Ela toca, e lê o que entrou na versão atual — em português de produto, não em nome de commit. 4. Querendo, alcança as versões anteriores a partir dali. 5. A marca some quando ela abre o painel, não quando fecha — é ali que a leitura acontece, e é ali que a tela chama a rota. Fechar não é um ato de leitura, e alguém que abra e feche sem ler já viu o que havia para ver.
A leitura é opcional e nunca interrompe. Nada abre sozinho, nada bloqueia a tela, nada espera confirmação. Quem nunca clicar continua trabalhando sem perder nada, e é isso que separa este ícone do banner do PWA, que interrompe porque precisa de uma ação.
E o banner do PWA continua existindo, com o papel dele. Ele pede uma ação — recarregar para pegar o código novo — e não conta o que mudou. Este ícone conta o que mudou e não pede nada. Os dois vão aparecer juntos depois de um deploy, e a spec 0005 já resolveu o primeiro; o que esta acrescenta é o conteúdo que faltava ao aviso.
7.1 Desktop¶
Qual versão é "a versão", e por que a distinção importa. O produto responde com a versão que este navegador está rodando — a que veio embutida no código carregado —, e não a que o servidor está servindo. As duas divergem numa janela real: quem está com a aba aberta continua com o código antigo até aceitar o banner do PWA. Como a pergunta que motiva tudo isto é a do suporte — "qual versão você está usando?" —, responder pela do servidor seria responder errado justamente na hora em que a pergunta é feita.
E quando as duas divergem, o painel oferece a saída. Havendo versão mais nova no servidor, ele diz isso e traz um botão de recarregar — não só a instrução. O detalhe importa porque o banner do PWA tem um "Depois" que o esconde: quem usou aquele botão ficaria sem caminho nenhum se aqui houvesse só texto. É a única ação do painel; ele não recarrega nada por conta própria.
O ícone entra na barra superior, à esquerda do alternador de tema, no mesmo agrupamento — é onde moram os controles que são do aplicativo e não da tela. Ele é um IconButton do mesmo tamanho dos vizinhos, com rótulo acessível "Novidades da versão".
Com novidade não lida, ele ganha um ponto — o mesmo desenho de ponto que a sider já usa para "há algo a ver aqui", e não um número: quantas versões a pessoa deixou de ler não é uma informação que mude o que ela vai fazer.
O clique abre um painel ancorado no ícone, no canto superior direito, como o menu do avatar já faz — e não um diálogo centralizado. O que se lê ali é consulta, não decisão: um diálogo modal escureceria a tela inteira para uma leitura que a pessoa pediu de passagem.
O painel tem, de cima para baixo:
| Onde | O quê |
|---|---|
| Topo | a versão que este navegador está rodando e a data dela — as duas vêm embutidas no código carregado, então aparecem juntas e sem espera. Nada no topo depende de rede |
| Corpo | o que entrou nesta versão, em itens curtos, na língua de quem usa |
| Aviso | quando o servidor tem versão mais nova, uma faixa entre o topo e o corpo, com o botão de recarregar |
| Rodapé | um caminho para as versões anteriores |
Cada versão empilhada mostra o mesmo que a atual, sem o aviso: número, data e os itens. É a mesma anatomia, e é o que permite comparar duas versões correndo o olho.
O contrato entre quem escreve a nota e o painel é uma linha só: cada item começa com -, e é isso que o painel desenha — uma lista, sem título, sem negrito, sem aninhamento. Qualquer outra coisa no arquivo é ignorada, o que dá espaço para quem escreve deixar comentário para si mesmo sem que ele apareça para o cliente.
A regra é essa porque o painel é estreito e a leitura é de passagem. Um parágrafo de cinco linhas ali não é lido; e sustentar formatação livre significaria decidir como cada coisa se comporta em 375px, o que é uma feature de renderização inteira para um conteúdo que cabe em frases.
As versões anteriores abrem no próprio painel, empilhadas abaixo da atual, e não numa tela nova. Quem quer arqueologia rola; quem quer só a última já leu e fechou. Uma tela dedicada a histórico de versões é o que a seção 5 deixou fora de escopo.
Cinco por vez, com um "ver mais" no fim. Num painel ancorado a altura é o recurso escasso, e um produto de dois anos teria dezenas de versões — carregar todas de uma vez faria o painel crescer sem limite pela informação que menos importa. O "ver mais" é local e instantâneo — não há leva a buscar. Não havendo versão anterior nenhuma — o caso do primeiro deploy depois desta feature existir — ele não aparece, porque um rodapé que não leva a lugar nenhum é pior que rodapé nenhum.
No dev-remote a versão diz que está à frente. Só o /ship publica versão, e ele deploya produção — então o dev-remote nunca tem uma versão própria. Em vez de mentir ou de calar, ele mostra a última publicada mais o quanto o ambiente andou depois dela: 1.5.0 +7. É a verdade sobre um ambiente de desenvolvimento, que está sempre à frente do que foi ao ar, e responde a pergunta que se faz ali — "isto já tem aquilo que eu acabei de mesclar?".
A versão também fica legível fora do painel, no menu do avatar, junto do nome e do e-mail — é o lugar onde já se lê "quem sou eu neste sistema", e "qual versão é esta" é da mesma família. Aquele menu só existe depois que a identidade carrega, então não há estado de carregamento a descrever ali: ou o menu não está na tela, ou a versão está nele. Isso importa porque a pergunta mais frequente do suporte não é "o que mudou", é "qual versão você está usando" — e a pessoa precisa conseguir responder sem saber o que é um painel de novidades.
7.2 Mobile (375px)¶
A barra superior do mobile não recebe o ícone, e isso é a spec 0011 (RN8) sendo respeitada: lá não há avatar nem alternador de tema, porque conta, tema e sair têm um caminho único — o rodapé da gaveta. Pôr um ícone de novidades na barra criaria o segundo caminho que aquela decisão existe para não ter.
O ponto de entrada não é o rodapé da gaveta, e a conta é o motivo. Aquele rodapé é uma faixa de 64px numa gaveta de 300px, com avatar de 36px e dois botões de 44px — sobram cerca de 140px para nome e e-mail. Um terceiro botão come 44px e derruba essa coluna para menos de 100px, onde nome e e-mail truncam quase sempre. Tirar a versão em texto dali não devolveria nada: quem gasta o espaço é o botão.
É uma linha própria na gaveta, logo acima do rodapé de conta, e ela carrega as duas coisas ao mesmo tempo:
Versão 1.5.0 · novidades •
O número responde "que versão é esta?" sem tocar em nada, o resto da linha é o toque que abre o painel, e o ponto à direita é a marca de não lido. O sufixo do dev-remote aparece aqui também — Versão 1.5.0 +7 —, e no menu do avatar do desktop: é a mesma versão, escrita do mesmo jeito em todo lugar que a exibe. Uma linha de lista é o que a gaveta já sabe desenhar, tem os 44px de alvo por construção, e não disputa espaço com o nome de ninguém.
A marca precisa ser vista sem abrir a gaveta, senão ela não avisa nada: o mesmo ponto aparece também na aba "Mais", que é a aba que abre a gaveta. São dois pontos, e não um: o da aba diz "há algo lá dentro", o da linha diz "é aqui".
Isso é capacidade nova na barra de abas, e não reaproveitamento — vale registrar para ninguém descobrir no meio da implementação: hoje ela só exibe contador numérico, e a aba "Mais" é montada sem marca nenhuma. O ponto existe na sider, e é dela que o desenho vem.
O painel, em 375px, é o diálogo de tela cheia que o app já usa — o mesmo fullScreen de ConfirmDialog, PendDialog, GuaranteeCreateDialog e SignatureDialog. Não é o popover ancorado do desktop: um popover preso a um ícone que vive dentro da gaveta seria conteúdo dentro de conteúdo, e a leitura é longa demais para caber ali.
A gaveta fecha quando o painel abre, e fechar o painel devolve a pessoa à tela em que estava — não à gaveta. Ela abriu a gaveta para chegar aqui; devolvê-la ao menu seria fazê-la fechar duas coisas para voltar ao trabalho.
Mesmo conteúdo do desktop, na mesma ordem, e nada rola na horizontal.
Nada desta feature deixa de existir no celular. As duas coisas que a jornada B entrega — ler a versão e ler o que mudou — são completáveis em 375px.
O PWA não é afetado: nenhuma rota nova, nenhum asset novo, nenhuma tela offline. O painel lê dado que vem da API, e sem rede ele cai no estado de erro descrito abaixo.
7.3 Estados¶
Carregando — e ele quase não existe. O cabeçalho e os itens vêm do código carregado, então aparecem de imediato: não há chamada a esperar para ler a nota. O único "Carregando…" do painel é o da faixa que compara com a versão do servidor, e ele vive na própria faixa, não no corpo.
Sem novidade nenhuma. Acontece no primeiro deploy depois desta feature existir, e para quem já leu tudo. O ícone não tem marca; o painel abre, mostra a versão atual e diz que não há nada novo desde a última vez que você olhou — e continua oferecendo as versões anteriores. "Vazio" aqui não é falta de dado, é a informação.
Quem nunca olhou. Usuário novo, primeiro login: ele não recebe a marca de não lido, e o painel abre normalmente. Marcar tudo como não lido daria as boas-vindas com um aviso sobre mudanças que ele nunca viu acontecer — a linha de base de quem entra é a versão do dia em que entrou.
Versão sem item nenhum. O contrato permite um arquivo só com comentários, e nesse caso o painel diz que esta versão não trouxe mudança visível — é diferente de "não há nada novo desde a última vez que você olhou", que fala da pessoa. Aqui fala da versão, e é a resposta honesta para um deploy que só mexeu em infraestrutura.
Erro ao carregar — e é preciso dizer o que falhou. A nota não pode falhar: ela está no bundle. O que pode falhar é a comparação com o servidor, e o erro fica confinado à faixa dela, com "Tentar novamente" — o padrão das listas do app. O resto do painel continua inteiro, porque não dependia daquilo.
E o ponto de não lido não muda com o erro: ele fica como estava. Apagá-lo por causa de uma chamada que falhou faria a novidade sumir sem ter sido lida.
Sem permissão. Não existe: o painel é para todo mundo que consegue entrar, e é por isso que o texto é de produto. A consequência é que a nota de versão é lida também pela imobiliária parceira — quem escrever precisa saber disso.
E o painel do SaaS admin herda o ícone, de propósito. Ele usa a mesma casca desde a spec 0033, e como não há porteiro o ícone aparece lá também. É o desfecho certo: quem administra a plataforma é quem mais precisa saber que versão está no ar.
Versão desconhecida. É o estado que existe hoje em produção, e que esta spec precisa não reproduzir: se o produto não souber qual versão está servindo, ele diz isso — "versão não identificada" — em vez de exibir um número que pode estar errado. Hoje ele responde 0.1.0 desde o commit inicial, e responder errado é pior que não responder.
8. Revisão da experiência¶
Preenchido pelo laço do /spec new, rubrica .claude/rubrics/experiencia.md. Registrar todas as rodadas, inclusive as reprovadas.
| Rodada | Critical | Important | Minor | Veredicto |
|---|---|---|---|---|
| 1 | 0 | 5 | 6 | Reprovada |
| 2 | 0 | 4 | 6 | Reprovada |
| 3 | 0 | 0 | 8 | Aprovada |
Rodada 1 — o que foi apontado e o que se fez. Três dos cinco Important faziam construir a tela errada, e os outros dois eram decisão do usuário.
O primeiro era o que mais custava: a spec não dizia qual versão o produto mostra — a que o navegador está rodando ou a que o servidor serve. As duas divergem numa janela real, porque quem está com a aba aberta segue com o código antigo até aceitar o banner do PWA, e é exatamente nessa janela que a pergunta do suporte é feita. Ficou decidido: a do navegador, e quando o servidor tiver outra mais nova o painel avisa e manda recarregar.
O segundo: a "folha que sobe de baixo" do mobile é padrão que este app não tem — não há Drawer anchor="bottom" nem gesto de arrastar em lugar nenhum —, e ele já tem outro para a mesma função, o Dialog fullScreen usado em cinco telas. Virou esse.
O terceiro é aritmética: o rodapé da gaveta não comporta o que a spec punha nele. São 64px de altura numa gaveta de 300px, com avatar de 36px e dois botões de 44px — sobram ~140px para nome e e-mail, e um terceiro botão derruba isso para menos de 100px. A versão em texto saiu daquela linha e ganhou faixa própria.
Os dois [PERGUNTAR AO USUÁRIO] foram respondidos: a nota é arquivo no repositório, gravado pelo /ship e revisado no editor antes de uma segunda passada — e não um prompt de terminal, que é a pior superfície para escrever texto de produto; e o dev-remote mostra a última versão publicada mais o quanto andou depois dela (1.5.0 +7), em vez de calar ou mentir.
Dos Minor: a barra de abas não sabe exibir ponto hoje (só contador numérico) — é capacidade nova, e agora está dito; as versões anteriores ganharam limite de cinco com "ver mais"; ficou escrito que a gaveta fecha quando o painel abre e que fechá-lo devolve à tela, não ao menu; quem nunca olhou não recebe marca de não lido; e o painel do SaaS admin herda o ícone de propósito, porque quem administra a plataforma é quem mais precisa saber a versão.
Rodada 2 — a spec continha a conta que condenava o próprio desenho. O achado mais caro foi esse: a rodada 1 pôs o ícone no rodapé da gaveta e escreveu a aritmética que mostra que ele não cabe — 64px de faixa, avatar de 36, dois botões de 44, sobrando ~140px para nome e e-mail, e o terceiro botão derrubando isso para menos de 100. Eu tinha tirado a versão em texto daquela linha achando que resolvia; não resolve, porque quem gasta o espaço é o botão. O ponto de entrada saiu do rodapé e virou uma linha própria da gaveta, que carrega o número da versão, o toque que abre o painel e a marca de não lido — e tem os 44px por construção.
Os outros três: a spec não dizia se o "não lido" acompanha a pessoa ou o navegador (é a pessoa, no servidor, como o usuário decidiu na entrevista — faltava estar escrito na Parte II, porque é comportamento visível); não havia contrato entre quem escreve a nota à mão e o painel que a desenha, e agora há um de uma linha só (- vira item, o resto é ignorado); e não estava dito em que passada se digita um major — é na primeira, antes de o arquivo existir, senão a nota nasce com o número errado no nome.
Dos Minor: a faixa da gaveta que eu citei como "discreta" é na verdade um chip âmbar de alto contraste, e a comparação saiu; ficou dito que a versão vem do build e a data vem do servidor, o que explica o cabeçalho aparecer antes da chamada; o aviso de versão nova ganhou botão e não só instrução, porque o "Depois" do banner do PWA deixaria quem o usou sem caminho; o menu do avatar não tem estado de carregando (ou ele não está na tela, ou a versão está nele); o "ver mais" ganhou carregando e ausência; e o ponto aparece nos dois lugares — na aba "Mais" e na linha —, porque quem abriu a gaveta por outro motivo não teria sinal nenhum.
Rodada 3 — aprovada, com oito Minor, sete fechados. O que mais valia era um defeito de escrita minha: a tabela do painel tinha ficado quebrada, com a linha do rodapé órfã depois de três parágrafos intercalados — ela renderizava fora da tabela, e quem lesse rápido perderia o rodapé do painel.
Fechados junto: a jornada A ganhou o passo do commit e push da nota, que o /ship já exige hoje ao recusar main local à frente; ficou dito que a segunda passada não regrava o arquivo, senão ela apagaria a revisão que as duas passadas existem para permitir; o estado de "versão sem item nenhum" entrou, e é diferente de "nada novo desde a última vez que você olhou" — um fala da versão, o outro da pessoa; o sufixo +7 do dev-remote vale nos dois viewports; a anatomia de cada versão empilhada e o lugar do aviso de versão nova entraram na tabela; e o ponto de não lido não some quando a chamada falha, senão a novidade sumiria sem ter sido lida.
Resultado: aprovada na rodada 3.
Pendências aceitas: uma, e é decisão de produto que ficou para o usuário — a nota da versão que estreia esta feature deve aparecer para quem já usa o produto? Pela regra escrita ("a linha de base de quem entra é a versão do dia em que entrou"), quem já está dentro veria a primeira nota; quem entrar depois, não. É o desfecho provável e razoável, mas está implícito, e a Parte III é onde ele vira regra.
Parte III — Detalhamento¶
Não preencher antes das revisões das seções 6 e 8 estarem aprovadas.
9. Regras de negócio¶
RN1 — A versão é semver, e quem a decide é o /ship, com você confirmando. MAJOR.MINOR.PATCH. O comando propõe: entrou spec desde a última versão → minor; entraram só fix e tweak → patch; major nunca é proposto, e sobe só quando você digita. A proposta é sugestão, não imposição — o número final é o que você confirmar.
O sinal que ele lê é o diretório, não a mensagem de commit. git diff --name-only <última tag>..origin/main -- spec/ fix/ — havendo um spec/NNNN-*/spec.md novo, propõe minor; havendo só fix/NNNN-*/fix.md, propõe patch; não havendo nem um nem outro (o caso do /tweak, que não deixa arquivo), propõe patch também. É a heurística inteira, e ela é deliberadamente burra: a proposta existe para poupar digitação, não para acertar sozinha.
Não havendo tag nenhuma, o intervalo é o repositório inteiro — que é o estado de hoje, e o do primeiro deploy deste mecanismo. Sem <última tag>, o git diff corre desde o commit inicial, e a proposta sai minor por causa das 43 specs que ele encontra. Isso não incomoda: a RN1b já fixou 0.1.0 como a primeira versão, a nota dela é escrita à mão (é a desta spec), e pela RN1a o pré-voo, achando 0.1.0.md no disco, lê em vez de propor. A proposta nem chega a ser mostrada.
RN1a — A decisão da versão acontece antes de a nota existir, e o nome do arquivo é onde ela fica guardada. Não há estado escondido entre as duas passadas do /ship: a versão confirmada é o nome do arquivo que a primeira passada gravou.
Daí sai o comportamento do pré-voo, que é de duas naturezas conforme o que ele acha no disco:
- Achando uma nota de versão maior que a última tag, ele lê em vez de propor. Aquele arquivo é a decisão já tomada; o pré-voo imprime a versão dele e sai com 0. Renomear o arquivo de
1.6.0.mdpara2.0.0.mdé, portanto, a forma de mudar a versão depois de decidida — e ela funciona porque o pré-voo lê o nome, não recalcula a proposta. - Achando mais de uma nota acima da última tag, ele para com
exit 6e lista os arquivos. Não escolhe a maior nem a mais recente: duas notas não tagueadas querem dizer que um/shipanterior foi abortado depois de gravar o rascunho, e adivinhar ali publicaria a nota errada com o número errado. É estado raro e barato de resolver — apagar o rascunho abandonado —, e o comando diz isso em vez de decidir sozinho.
Código próprio, e não o 5, porque as duas situações pedem coisas diferentes de quem lê. O 5 quer dizer "não há nota, vou gravar uma e te perguntar o número"; o 6 quer dizer "há nota demais, resolva o disco antes" — e o /ship não grava nada no 6, senão acrescentaria um terceiro arquivo ao problema. Tratá-los com o mesmo código faria o comando gravar um rascunho novo exatamente quando o que sobra é rascunho. - Não achando nota nenhuma, ele propõe pela RN1 e sai com 5. Aí o /ship — que é agente, não script — mostra a proposta e pergunta o número, antes de gravar. É neste ponto que um major sobe: a resposta vira o nome do arquivo, e o arquivo passa a ser a verdade da passada seguinte.
Ou seja, o gate humano do /ship deixa de ser um e passa a ser dois, em momentos diferentes: na primeira passada se decide o número, na segunda se confirma o texto e o deploy. Sem o primeiro, o número proposto seria gravado sem ninguém ter dito nada, e a RN1 — "major sobe só quando você digita" — não teria onde acontecer.
RN1b — A primeira versão é 0.1.0 (decisão do usuário, 2026-08-22). Não há tag nenhuma hoje, e 0.1.0 é o número que o FastAPI e o package.json já declaram — a contagem continua em vez de recomeçar, e o produto segue dizendo que ainda não é 1.0. A partir dela a RN1 vale normalmente.
RN2 — A marca no repositório é uma tag anotada vX.Y.Z, criada depois de a produção provar que está no ar. Uma tag apontando para um deploy que falhou é pior que nenhuma, porque mente sobre o que está servindo. E ela é empurrada — tag que fica só na máquina de quem deployou não serve de marco para ninguém.
"Dar certo" é o Passo 4, não o deploy.sh sair com zero — e a diferença é a regra inteira. O próprio SKILL.md do /ship já diz isto nos Princípios: "Build/up sem erro não é prova de que a produção está servindo o código novo e saudável (…) quem prova é o Passo 4, e sem ele não se diz que subiu". O deploy.sh termina em git pull → make up → make migrate → make seed e não faz uma requisição sequer; a API pode estar em crash-loop, respondendo 500 ou servindo a imagem anterior, e os quatro comandos saem com zero do mesmo jeito.
Então a tag não mora no deploy.sh. Ela é criada e empurrada pelo /ship depois do Passo 4 — health respondendo {"status":"ok","db":"ok"}, raiz em 200 e HEAD deployado igual ao origin/main. Pôr a criação "ao fim do script" seria escolher exatamente o ponto que o skill documenta como não sendo prova, e o CA5a não pegaria: ele fala de deploy que falha, e este é o caso em que o script tem êxito e a produção não.
RN2a — O push da tag é exceção declarada à regra de não empurrar sozinho (decisão do usuário, 2026-08-22). O CLAUDE.md abre com "nunca fazer git commit nem git push automaticamente", e os Princípios do /ship repetem: "o ship não faz push". A tag é a única exceção, e ela é estreita por três razões: nasce depois de o deploy dar certo; não carrega commit nenhum que já não esteja em origin/main — é um rótulo sobre o que a produção acabou de servir; e o gate humano da segunda passada é o pedido expresso que a regra exige, porque quem confirma "pode subir como 1.6.0" pediu a v1.6.0 na mesma frase.
A exceção precisa ser propagada para os dois lugares que hoje afirmam o contrário, senão quem ler encontra uma regra que o código viola:
.claude/skills/ship/SKILL.md, nos Princípios — "o ship não faz push (regra doCLAUDE.md)";CLAUDE.mdda raiz, na seção Fluxo de desenvolvimento — "Nada é commitado, mesclado ou empurrado sem pedido explícito, com a única exceção do/tweak". Esta é a mais categórica das duas, e é a que ficaria contraditória se só oSKILL.mdfosse corrigido: passam a ser duas exceções, e a palavra "única" sai.
RN2b — Tag que já existe faz o /ship parar; push de tag que falha não desfaz nada. Achando vX.Y.Z já no repositório, o comando para e diz — aquela versão já foi ao ar, e remarcar a tag mentiria sobre qual commit está servindo. E se o deploy deu certo mas o git push da tag falhou, nada há a reverter: a produção está no ar e correta, e o que falta é um comando local a repetir, que o /ship imprime.
RN3 — A nota de versão é um arquivo versionado, e ele mora dentro do apps/web, em apps/web/src/versoes/X.Y.Z.md. Ela entra no repositório pelo caminho normal — commit e push — antes do deploy, porque o /ship deploya origin/main e já recusa hoje com o main local à frente.
Um arquivo, e não um par. A tentação é guardar o texto em spec/versoes/ — perto das specs, longe do código — e gerar um .json dentro do apps/web para o bundle consumir. Isso cria dois artefatos que precisam concordar, e nenhum lugar óbvio para gerar o segundo: gerado antes do commit, ele depende de alguém lembrar; gerado no build, ele não existe, porque o web.Dockerfile copia só apps/web/. Pior: um par que se copia levaria para o bundle público as linhas que a RN4 promete ignorar — o comentário que quem escreve deixa para si mesmo.
Dentro do apps/web não há par, não há geração, não há verificação de sincronia e não há divergência possível. É o mesmo lugar onde o catálogo de tutoriais da spec 0042 vive, e pelo mesmo motivo.
RN4 — O contrato do arquivo de nota tem duas chaves, e nada mais. Linha começando com - vira item; uma linha data: AAAA-MM-DD é a data da versão; qualquer outra linha é ignorada. Sem título, sem negrito, sem lista aninhada.
Revisada pelo fix 0024 (2026-10-15): a linha indentada logo abaixo de um item é continuação do mesmo item. As notas quebram linha como o resto do repositório, e a continuação sumia da tela; linha em branco encerra o item, e linha solta sem indentação segue ignorada.
Ignorar o resto é o que permite a quem escreve deixar anotação para si mesmo sem que ela chegue ao público, e o que dispensa decidir como cada construção de Markdown se comporta em 375px.
A data é a do rascunho, e isso é deliberado. Rascunho escrito na sexta e deployado na terça publica a data de sexta — e o CA7 proíbe a segunda passada de regravar, porque regravar apagaria a revisão. Não vale a pena mais mecanismo que isso: a data é uma linha do arquivo como as outras, e quem se incomodar com a defasagem edita a linha antes de commitar.
A data é escrita pelo /ship, não por você, no momento em que ele grava o rascunho — e não vem do git nem da API. Do git ela não pode vir: o .git não entra na imagem do web. Da API também não, porque as notas não passam por lá (RN4a). Escrita no arquivo, ela viaja junto do texto que descreve.
E o que a RN4 ignora fica mesmo fora do bundle? Não: o arquivo inteiro vai para o bundle, e o que a RN4 ignora é ignorado na hora de desenhar a tela. Como a nota é pública (RN11), a anotação que alguém deixar no arquivo é pública também — ela some da tela, não do mundo. Quem escreve precisa saber disso, e por isso está aqui e não só na RN11.
RN4a — As notas vão no bundle do apps/web, e nada nelas é buscado. O painel abre com o texto já em memória: sem chamada, sem espera, sem falha possível. É o que garante a RN5 — as notas que a pessoa lê são as da versão que ela está rodando, e não as de uma versão que o servidor tem e o navegador dela ainda não.
Só duas coisas do painel são assíncronas, e é bom saber quais: a marca de não lido, que chega no /auth/me junto do resto da identidade e portanto já está carregada quando a tela monta; e a comparação com a versão do servidor (RN7), que é uma chamada ao /health. É essa chamada, e só ela, que pode falhar — o estado de erro da 7.3 é sobre ela, não sobre as notas. O "ver mais" das versões anteriores é local e instantâneo.
RN5 — A versão que o produto exibe é a que aquele código embute. Ela entra no build do apps/web como variável de ambiente, do mesmo jeito que o GIT_COMMIT da spec 0042 entra no build da API. Não é consultada ao servidor: a pergunta que motiva tudo isto é "qual versão você está usando?", e quem responde é o código que está rodando no navegador de quem responde.
RN5a — Sem versão no build, o produto diz que não sabe. O valor cai para "não identificada", e a tela mostra isso — nunca um número que possa estar errado. É a lição do 0.1.0 congelado e do GIT_COMMIT que hoje responde "desconhecido" em produção: um mecanismo que depende de alguém lembrar de passar um argumento vai ficar em branco, e responder errado é pior que não responder.
RN6 — Fora da produção, a versão diz o quanto o ambiente andou depois dela. O dev-remote mostra X.Y.Z +N, onde N é o número de commits desde a tag. Só o /ship publica versão, então um ambiente de desenvolvimento está sempre à frente — e é isso que se quer saber ali.
RN7 — Quando o servidor tem versão mais nova que a do navegador, o painel diz e oferece recarregar. Mais nova, e não apenas diferente: depois de um rollback o servidor fica com versão mais velha, e oferecer "recarregar para atualizar" ali mandaria a pessoa para trás achando que vai para frente. Divergindo para baixo, o painel não diz nada — o navegador dela tem o código que o rollback tirou do ar, e recarregar vai resolver isso sozinho na próxima vez que ela recarregar por qualquer motivo.
RN7a — A comparação tem uma regra só, e ela cobre o que não é comparável. Compara-se (MAJOR, MINOR, PATCH, N) numericamente, com N = 0 quando não há sufixo — assim o +N da RN6 entra na conta, e no dev-remote um rebuild à frente do navegador dispara o aviso como dispararia na produção. Basta um dos dois lados não ser identificável — o /health sem APP_VERSION, ou o bundle no fallback da RN5a — para o painel não dizer nada: sem saber qual é a versão de um dos lados, "há uma versão mais nova" é afirmação sem base, e o silêncio é o comportamento honesto.
Isso não é hipótese remota: é o estado da produção de hoje para o commit, e é o estado em que o rollback documentado no SKILL.md (git reset --hard + make up, sem APP_VERSION) deixa a box. O caminho de volta do deploy ruim não pode acender um aviso que empurra todo mundo de volta para a versão que acabou de sair do ar.
A comparação acontece ao abrir o painel, e não em laço: é uma chamada por abertura, no momento em que alguém está olhando. A comparação é entre a versão do build do apps/web e a que a API declara. O botão existe porque o banner do PWA tem um "Depois" que o esconde: quem o usou ficaria sem caminho se aqui houvesse só a instrução.
RN8 — O que cada pessoa já leu é guardado no servidor, por usuário. Uma coluna em users com a última versão vista. Quem leu no computador não vê a marca de novo no celular, e limpar o navegador não ressuscita o aviso.
A gravação só avança, nunca retrocede. Recebendo uma versão mais velha que a guardada, o servidor mantém a que já estava. O cenário é criado pelo próprio desenho desta spec: a RN7 existe porque há navegadores atrasados, e uma aba antiga no celular marcaria como lida uma versão anterior à que a pessoa já leu no computador — fazendo a marca voltar para uma nota que ela já viu. Sem esta linha, a decisão cara desta spec (guardar no servidor, e não no navegador) se desfaz sozinha.
O que se envia é sempre X.Y.Z puro, sem o +N do dev-remote (RN6): o sufixo diz o quanto o ambiente andou, e não é uma versão publicada. E com a versão não identificada (RN5a) a tela não chama nada — não há o que marcar como lido quando não se sabe o que se leu. Sem essas duas regras, o dev-remote — que é onde o e2e roda — tomaria 422 a cada tentativa e a marca nunca sumiria.
RN9 — A linha de base de quem entra é a versão do dia em que entrou, e ela é gravada na criação. Todo caminho que cria usuário grava a versão corrente da API em last_seen_version: POST /users, o convite de membro, o convite de imobiliária, o cadastro público e o init_db.
O servidor normaliza antes de gravar, e é a mesma régua da RN8 do lado de cá. A versão da API no dev-remote traz o +N da RN6, e sem o build arg ela é o valor de desconhecido — nenhum dos dois é um X.Y.Z publicado. A regra é uma só: grava só a parte X.Y.Z, e grava nulo quando não há versão identificável.
São seis caminhos, e o sexto é exceção declarada. Além dos cinco acima, o seed_e2e.py cria os oito usuários da suíte Playwright (as chamadas de _upsert_user entre :218 e :280) — e ele grava nulo de propósito. Gravando a versão corrente, todo usuário do e2e nasceria "já leu", e o CA10 ficaria sem sujeito no único ambiente onde a suíte roda. O estado que o seed precisa produzir é o de quem nunca abriu o painel; o outro lado da RN9 — quem nasce depois da versão e não recebe marca — se testa criando um usuário pela API no meio do teste, que é o que o CA12 faz.
E o seed precisa zerar, não só "não gravar" — a diferença é o que mantém o CA10 testável. O _upsert_user do seed_e2e.py:113 é idempotente e, no ramo de usuário já existente, atualiza só password_hash, is_active, tenant_id e partner_agency_id. Não bastasse ele deixar de gravar a versão na criação: o dev-remote é banco persistente e compartilhado, então o primeiro teste que abrir o painel deixa adm@e2e.test marcado como lido para sempre, e o CA10 passa a falhar por régua suja, no único ambiente onde ele roda. last_seen_version entra na lista de campos que o ramo de upsert reescreve, com None.
É a armadilha que o CLAUDE.md já registrou na spec 0025 — estado compartilhado que um teste deixa sujo derruba outro que nada tem a ver com ele. Aqui ela é pior, porque o teste derrubado é o do próprio estado. Sem isso, o campo nasceria no dev-remote com um valor que o próprio validador da rota recusa com 422, e a RN9 seria inverificável exatamente no ambiente onde o e2e roda. Sem isso, "usuário novo" e "usuário que já existia" ficam indistinguíveis — os dois teriam o campo nulo —, e a RN9 e a RN10 se contradiriam.
Usuário novo não recebe marca de não lido: dar as boas-vindas com um aviso sobre mudanças que ele nunca viu acontecer é ruído, não informação.
RN10 — A nota que estreia esta funcionalidade aparece para quem já usa o produto. É a exceção deliberada à RN9, e ela vale uma vez só. O mecanismo é o campo nulo: a migration não faz backfill, então quem já existe fica com last_seen_version nulo, e nulo conta como "nunca leu" — recebe a marca. Quem for criado depois cai na RN9, que grava a versão corrente na criação, e não recebe nada.
É a ausência de backfill que separa os dois casos, e é por isso que ela é deliberada e não esquecimento. Sem essa exceção, a funcionalidade estrearia invisível para exatamente as pessoas para quem ela foi feita.
RN11 — Não há porteiro, e a nota é conteúdo público (decisão do usuário, 2026-08-22). Todos os papéis a leem — inclusive o AGENCY_USER da imobiliária e o ADMIN_GLOBAL da plataforma —, e quem não tem conta também: o texto vai no bundle do apps/web, que o nginx serve a qualquer visitante da tela de login, e a versão vai no /health, que é público desde a spec 0042.
Isto é aceito, e vira regra de quem escreve: a nota de versão é escrita para o mundo. Nada de nome de cliente, valor, dado de garantia ou nome de pessoa. A revisão humana da jornada A é onde isso se verifica, e é a única barreira — não há, nem vai haver, controle de acesso protegendo esse texto.
RN12 — O /ship não deploya sem nota, e o pré-voo é onde isso para. Havendo commits desde a última versão e não havendo apps/web/src/versoes/X.Y.Z.md, o preflight.sh sai com exit 5 — um dos dois códigos novos, ao lado do 6 da RN1a e dos 0/2/3/4 que ele já documenta — e diz qual arquivo falta. Recusar ali, e não no deploy, é o que mantém a promessa do pré-voo: ele não toca na produção.
E o exit 5 é o fim normal da primeira passada, não um acidente. Não há duas coisas acontecendo: a jornada A passo 4 é este exit. O arquivo legitimamente não existe na primeira passada — é ela que o cria —, então a sequência é uma só: o pré-voo propõe a versão, não acha a nota, o /ship grava o rascunho e sai com 5 dizendo "escrito, revise e commite". Na segunda passada o mesmo pré-voo acha a nota e sai com 0.
Um exit 5 sem rascunho gravado ao lado seria a leitura errada da regra: o que a RN12 impede é o deploy sem nota, não o /ship de escrever a nota. O que ela custa a quem esqueceu é uma passada a mais, e é isso que impede a nota de virar "depois eu escrevo" — que é como ela deixaria de existir.
RN12b — O /tweak também publica versão, e ele monta a nota sozinho (decisão do usuário, 2026-08-23). O /tweak chama o mesmo preflight.sh (.claude/skills/tweak/SKILL.md:114) e não deixa arquivo de registro por decisão — então, sem regra própria, ele passaria a tomar exit 5 para sempre e o único comando que deploya numa passada travaria.
A saída não é isentá-lo: um deploy que vai ao ar sem versão e sem tag faz a versão exibida deixar de descrever o code base que está servindo, que é o problema da Parte I voltando pela porta dos fundos. O /tweak publica a versão que a RN1 propuser e escreve a nota ele mesmo, a partir das mensagens de commit — que ele já exige que digam o porquê, numa frase que sobreviva a seis meses. É o insumo que a jornada A monta com o script; aqui ele já está pronto por construção.
Normalmente é patch, e quando não é, o "não" é o certo. A heurística da RN1 é a mesma nos dois comandos, e ela olha o que entra em produção, não quem está deployando: havendo uma spec mesclada na main e ainda não publicada, é ela que sobe junto, e a versão é minor. Seria errado forçar patch ali — o número passaria a descrever o comando em vez do código, que é a confusão que a Parte I registra. O /tweak usa o PROPOSTA= do pré-voo, e não um literal.
E não há segunda passada: o /tweak tem um gate humano, e é nele que a nota aparece, ao lado do pré-voo. Quem quiser mudar o texto muda ali antes de confirmar. Duas passadas existem no /ship porque a nota de uma spec é longa e merece editor; a de um tweak é a frase que a pessoa acabou de escrever no commit.
RN12a — Nota sem item nenhum é estado legítimo, e diferente de nota ausente. Um arquivo que só tem comentários publica uma versão que não trouxe mudança visível, e o painel diz isso. Um deploy que só mexe em infraestrutura é o caso real disso; o que a RN12 impede é o esquecimento, não o silêncio deliberado.
10. Autorização e isolamento¶
A superfície nova de escrita é uma só, e ela não aceita alvo.
| Papel | Ler a versão e as notas | Marcar como lido |
|---|---|---|
ADMIN_GLOBAL | sim | sim, para si |
ADMIN_TENANT | sim | sim, para si |
ANALYST | sim | sim, para si |
OPERATOR | sim | sim, para si |
VIEWER | sim | sim, para si |
AGENCY_USER | sim | sim, para si |
| não autenticado | sim, e por desenho (RN11) — a versão vem do /health, público desde a 0042, e as notas vêm do bundle, que o nginx serve na tela de login | não |
Nenhuma permissão nova. A leitura não passa pela API (ver a seção 11: as notas vão no bundle), e a escrita é sobre o próprio usuário. Criar um versions:read seria inventar um porteiro para uma porta que a RN11 decide manter aberta.
A rota de marcar como lido não recebe id de usuário, e isso é a decisão de segurança desta spec. PATCH /api/v1/auth/me/last-seen-version age sobre current_user, sempre. Uma rota PATCH /users/{id}/last-seen-version precisaria de porteiro, de filtro de tenant e do 404-antes-do-403 da RN4 da spec 0034 — e o que ela compraria é a capacidade de marcar como lido no lugar de outra pessoa, que ninguém pediu. Sem id, não há escalada a fechar.
Isolamento por garantidora: não se aplica, e é preciso dizer por quê. O dado é do usuário, não do tenant, e a rota nunca alcança usuário que não seja quem chama. Não há tenant_id a filtrar porque não há consulta a registro de outra pessoa. O ADMIN_GLOBAL não é caso especial aqui — ele marca a própria leitura como qualquer um, e não há listagem em que a ausência de filtro de tenant dele vazasse algo.
A fronteira plataforma × cliente fica intacta. A nota é a mesma para todo mundo porque o produto é o mesmo; ela não cita cliente, não cita tenant, e não é dado de negócio. Um ADMIN_GLOBAL lendo a nota não alcança nada do cliente por esse caminho.
O que a nota de versão não pode conter, e é regra de quem escreve: nome de cliente, dado de garantia, valor, nome de pessoa, ou qualquer coisa que só um tenant deveria ver. Pela RN11 o texto é público — vai para todos os tenants, para o canal, e para quem só abriu a tela de login. A revisão humana da jornada A é a única barreira, e a spec diz isso em vez de fingir que há outra.
11. Impacto por app¶
.claude/skills/ship¶
scripts/preflight.sh— passa a imprimir a versão que está no ar (a última tag alcançável pelo HEAD deployado) e a proposta (RN1), com o motivo. Continua imprimindoMIGRATE.SKILL.md— o Passo 2 (gate humano) ganha a versão e a nota; entra um passo entre o pré-voo e o deploy, que é o de gravar/revisar a nota (RN3); o Passo 4 ganha a criação e o push da tag, depois de as duas verificações passarem (RN2) — e não o Passo 3, porque odeploy.shsair com zero não é prova de que a produção subiu; o Passo 5 (fechar) passa a citar a versão. E os Princípios ganham a exceção da RN2a — hoje eles afirmam que "o ship não faz push", e deixar a linha como está faria o código contradizer o documento que o governa.scripts/deploy.sh— passaVERSIONcomo build arg para owebe para aapi. Não cria a tag: ela é do Passo 4, pela RN2. E passa oGIT_COMMITque hoje não passa — é o mesmo mecanismo, e deixar metade quebrada seria repetir o defeito que a Parte I registra.- Um script novo que monta o rascunho da nota a partir dos commits e dos registros de
spec/NNNN-*/spec.mdefix/NNNN-*/fix.mddesde a última tag, e o grava emapps/web/src/versoes/X.Y.Z.md. Não há diretóriotweak/— o/tweaknão deixa arquivo por decisão doCLAUDE.md("a mensagem de commit é o registro"), então o que entra dele no rascunho é a própria mensagem de commit.
apps/web/src/versoes/ — um arquivo, e ele mora onde o build enxerga¶
A nota é apps/web/src/versoes/X.Y.Z.md, e é o único artefato (RN3). Você a edita, o /ship grava o rascunho dela, o git a versiona e o bundle a lê — o mesmo arquivo nos quatro papéis. Não há .json gerado, não há passo de geração, e portanto não há o que verificar no make preflight além do que a RN12 já cobra no pré-voo do /ship.
Por que dentro do apps/web, e não em spec/versoes/. O web.Dockerfile de produção copia só apps/web/ (COPY apps/web/ ./), e o mesmo vale no dev-remote. Um arquivo em spec/versoes/ simplesmente não existe dentro da imagem: alcançá-lo exigiria alias no Vite, server.fs.allow e mudança nos dois Dockerfiles — que é exatamente o custo que o catálogo de tutoriais da spec 0042 evitou restringindo o glob ao próprio apps/web. Este segue o mesmo caminho, pela mesma razão.
E o Vite lê .md cru sem plugin nenhum: import.meta.glob("./*.md", { query: "?raw", eager: true, import: "default" }) devolve o texto de cada arquivo, e o index.ts aplica o contrato de uma linha da RN4 (- vira item, o resto é ignorado). É a mesma forma do src/tutoriais/ da 0042, trocando .json por texto.
.claude/skills/tweak¶
SKILL.md— o passo do gate humano ganha a versão proposta (patch) e a nota montada das mensagens de commit, e o ciclo ganha o commit da nota antes do push (RN12b). O trecho que hoje descreve o skill como "sem numeração, sem arquivo de registro" precisa dizer que o arquivo de nota de versão é a exceção — ela não é registro do ajuste, é o que o produto exibe.- A tag nasce depois do
curlde fechamento, e não dodeploy.sh— a RN2 vale igual aqui. O/tweakjá fecha verificando de fora (SKILL.md, seção O gate humano:curlno/healthe na raiz); é depois dessas duas respostas que a tag é criada e empurrada. Dizer só "deixa tag como o do/ship" abriria a segunda leitura — criar assim que odeploy.shretorna —, que é exatamente o defeito que esta spec já corrigiu uma vez, num caminho de código só. Dois caminhos, uma regra. - O script de rascunho é o mesmo do
/ship, chamado com o intervalo do tweak. Dois geradores divergiriam, e a nota do tweak sairia com outra cara sem que ninguém tivesse decidido isso.
CLAUDE.md da raiz¶
- A frase "com a única exceção do
/tweak" (seção Fluxo de desenvolvimento) passa a listar duas: o/tweake o push da tag pelo/ship(RN2a). Deixar só oSKILL.mdcorrigido faria o documento que governa o repositório contradizer o comando. - A tabela dos quatro comandos passa a dizer que
/tweake/shippublicam versão.
infra/prd e infra/dev-remote — o encanamento que hoje não existe¶
É aqui que o GIT_COMMIT da spec 0042 se perdeu, e esta spec não pode repetir o caminho. Hoje o web de produção não tem args nenhum no compose, e o web.Dockerfile não tem ARG/ENV. A cadeia inteira precisa existir, e nos dois ambientes:
infra/prd/docker-compose.yml— o serviçowebganhaargscomAPP_VERSION, e o serviçoapiganhaAPP_VERSIONao lado doGIT_COMMITque já está lá.infra/prd/web.Dockerfile—ARG APP_VERSION→ENV VITE_APP_VERSION, antes do build do Vite.infra/prd/api.Dockerfile—ARG APP_VERSION→ENV APP_VERSION, no desenho doGIT_COMMIT.infra/prd/Makefile— omake uprodadocker compose up -d --buildna box, lendo do ambiente do shell remoto: é por ali queAPP_VERSIONeGIT_COMMITchegam.infra/dev-remote/*— os mesmos quatro, com o+Nda RN6. Sem isso o dev-remote cai em "não identificada", e é contra ele que o e2e roda — o CA3 falharia por construção..claude/skills/devremote-rebuild/scripts/rebuild.sh— passa a calcular e passar a versão com sufixo.
apps/api¶
models/user.py— colunalast_seen_version,String(20), nullable. Em inglês, como os vizinhos (tenant_id,partner_agency_id,onboarding_step): o contrato de fio deste repositório é inglês, e o português fica nos nomes internos de método.schemas/user.py— o campo entra noMeResponse, e não noUserResponse. É estado de leitura de uma pessoa, e não há razão para a lista da Equipe contar a todos quem leu a nota de versão e quem não leu.api/v1/routers/auth.py—PATCH /auth/me/last-seen-version, e oMeResponsemontado à mão ali precisa do campo novo (foi o que a spec 0041 aprendeu: sem tocar neste arquivo o campo não sai).services/user_service.py,services/onboarding_service.pyedb/init_db.py— os cinco caminhos de criação de usuário gravam a versão corrente, normalizada (RN9). Odb/seed_e2e.pyé o sexto, e zera o campo de propósito — na criação e também no ramo de upsert do_upsert_user, senão o primeiro teste que abrir o painel suja o banco compartilhado do dev-remote e o CA10 deixa de ter sujeito.tests/test_authorization_coverage.py— oPATCH /auth/me/last-seen-versionentra em_SEM_PORTEIRO_POR_DESENHOcom o motivo escrito, ao lado doGET /auth/meque já está lá pela mesma razão. Sem isso o meta-teste do porteiro reprova, e com razão: a dispensa tem de ser declarada, não presumida.core/config.pyemain.py—APP_VERSIONno mesmo desenho doGIT_COMMIT, e o/healthpassa a devolvê-la (RN7). Oversion=do FastAPI passa a vir doAPP_VERSIONem vez do literal cravado nomain.py:484.apps/web/package.json:3fica congelado, e é decisão. A Parte I o cita junto domain.pycomo parte do problema, mas os dois não têm o mesmo destino: oversion=do FastAPI aparece (no Swagger, e agora no/health), enquanto o dopackage.jsonnão é lido por nada em runtime — o Vite não o injeta no bundle e o app não o importa. Mantê-lo em dia exigiria o/shipescrever num arquivo que ele não toca para mais nada, e opnpmreescrever o lockfile a cada versão. Fica em0.1.0, e quem quiser a versão do produto lê os lugares da RN5.xxxx_ultima_versao_vista_por_usuario— acrescenta a coluna, e deliberadamente não faz backfill: é o campo nulo que faz a RN10 valer para quem já usa o produto. Odowngradederruba a coluna, e aqui ele é simétrico — não há dado a preservar.
apps/web¶
src/versoes/— as notas em.mde umindex.tsque as lê comimport.meta.glob(query: "?raw"), ordena por semver e responde as perguntas da tela. Mesmo desenho dosrc/tutoriais/da spec 0042; nada é gerado, o.mdé a fonte.vite.config.ts—VITE_APP_VERSION, com "não identificada" como padrão (RN5a).components/app-layout/index.tsx— o ícone na barra, à esquerda do tema; a versão no menu do avatar.components/mobile-shell/index.tsx— a linha da gaveta acima do rodapé de conta, e o ponto na aba "Mais" — que é capacidade nova da barra de abas, hoje só numérica.components/versao/— o painel, nas duas formas: popover ancorado no desktop,Dialog fullScreenem 375px. É aqui que a RN7a mora: a comparação(MAJOR, MINOR, PATCH, N)entre a versão do bundle e a do/health, e o silêncio quando um dos dois lados não é identificável. Fica no componente, e não num hook global, porque é a única tela que faz a pergunta. E é também aqui que o+Nda RN6 é retirado antes doPATCH— o corpo levaX.Y.Zpuro, senão o dev-remote toma 422 a cada tentativa e a marca de não lido nunca some (RN8). Um lugar só compara e envia; separar as duas coisas em arquivos diferentes é como o sufixo escaparia.shared/identity/store.tseproviders/auth.ts—last_seen_version: string | nullentra na interfaceIdentitye noUserResponsedo web (providers/auth.ts:12), que é o tipo TypeScript do que o/auth/medevolve — homônimo doUserResponseda API, mas equivalente aoMeResponsedela. Não é contradição com o que a seção 12 diz: lá o campo entra noMeResponsee fica fora doUserResponsedo Pydantic; aqui é o tipo local, em outro app. O valor já atravessa em runtime (o objeto inteiro vai parasetIdentity), mas sem o campo declaradouser.last_seen_versionnão compila — otscdo web está limpo hoje e continua sendo o portão.shared/hooks/useCurrentUser— nada a mudar na mecânica: ele assina o store, e o store se atualiza a cada navegação. O que muda é depois doPATCH: a tela chamasetIdentitycom o campo novo em vez de esperar a próxima rota, senão o ponto de não lido continuaria aceso na tela em que a pessoa acabou de ler.data-testidem tudo que é novo: o ícone, a linha da gaveta, o painel, o botão de recarregar, o "ver mais".
apps/e2e¶
- Spec nova cobrindo: a marca aparecendo para quem não leu e sumindo depois de ler; a persistência entre sessões (RN8) — que é o que distingue esta decisão da barata; o painel nos dois viewports sem rolagem horizontal; o
AGENCY_USERvendo o ícone (RN11); e o estado de versão não identificada. - Sem seed novo: o mundo é montado pela API, como nas specs 0038 a 0041.
12. Contrato de API¶
Ganha version, ao lado do commit que a spec 0042 acrescentou. É o que a RN7 compara.
Sem APP_VERSION no build, version vale "desconhecido" — literal, o mesmo default que o commit já usa em app/core/config.py:17, e nunca null nem ausente. O campo está sempre lá, com forma sempre string. É este valor que a RN7a lê como "lado não identificável" e que cala o aviso de versão nova, então ele precisa estar escrito aqui, que é onde quem for implementar a comparação vem procurar. No dev-remote o mesmo campo traz o +N da RN6 ("1.5.0 +7"), que a RN7a compara normalmente.
Corpo: { "version": "0.2.0" }. Age sobre quem chama, sempre — não há id no caminho nem no corpo (seção 10). Grava a versão que a pessoa acabou de ler.
| Erro | Quando |
|---|---|
401 | não autenticado |
422 | version ausente, ou fora do formato X.Y.Z — o +N do dev-remote não é enviado (RN8) |
Por que valida o formato: o valor é comparado com a versão do build para decidir a marca, e uma string livre ali faria a comparação silenciosamente sempre verdadeira ou sempre falsa.
MeResponse ganha last_seen_version: string | null. Nulo é "nunca leu" (RN9, RN10).
Nada mais muda no contrato. As notas não passam pela API: elas vão no bundle do apps/web, no desenho do catálogo de tutoriais da 0042. É o que faz o painel abrir sem rede e o que garante a RN5 — as notas que a pessoa lê são as da versão que ela está rodando, não as de uma versão que o servidor tem e o navegador dela ainda não.
13. Critérios de aceite¶
A versão
- [ ] CA1. Com o build feito pelo
/ship, o produto exibe a versão publicada — no menu do avatar (desktop) e na linha da gaveta (375px). - [x] CA2. Sem a variável no build, os dois lugares dizem "não identificada", e nunca um número (RN5a).
- [ ] CA3. No dev-remote, a versão vem com o sufixo
+Nnos dois viewports (RN6). - [x] CA4.
GET /healthdevolveversionjunto decommit, e oversion=do FastAPI passa a vir doAPP_VERSIONem vez do literal cravado nomain.py— o valor continua0.1.0enquanto essa for a versão publicada (RN1b); o que muda é de onde ele vem. - [ ] CA5. O
/shipcria a tagvX.Y.Ze a empurra, e ela aponta para o commit que foi ao ar (RN2). - [ ] CA5a. Deploy que falha não deixa tag — e isso inclui o
deploy.shsair com zero e a verificação do Passo 4 reprovar (health fora do ar, raiz fora de 200, ou HEAD diferente deorigin/main): aí também não há tag (RN2). - [x] CA5b. Com
vX.Y.Zjá existente, o/shippara antes do deploy e diz que aquela versão já foi ao ar (RN2b).
A nota
- [ ] CA6. O
/shiprecusa deployar quando há commits desde a última versão e não há nota (RN12), dizendo qual arquivo falta. - [ ] CA6b. O
/tweaknão trava no pré-voo: ele monta a nota da própria mensagem de commit, mostra no gate humano e sobe patch (RN12b). - [ ] CA6c. O
/tweakcria e empurra a tag depois docurlde fechamento responder, não quando odeploy.shretorna (RN2) — e falhando a verificação, não deixa tag. - [x] CA6a. Com duas notas acima da última tag, o pré-voo sai com exit 6, lista as duas e não grava rascunho nenhum (RN1a) — código diferente do
5, porque o que se pede de quem lê é outro. - [ ] CA7. A segunda passada do
/shipnão regrava a nota já editada. - [ ] CA8. Linha começando com
-vira item; qualquer outra linha do arquivo não aparece no painel (RN4). - [ ] CA9. Nota só com comentários publica a versão e o painel diz que não houve mudança visível (RN12a) — texto diferente do de "nada novo desde a última vez que você olhou".
A marca e o painel
- [ ] CA10. Quem nunca leu, e tem
last_seen_versionnula, recebe a marca (RN10). - [ ] CA11. Ao abrir o painel a marca some — e continua sumida em outro navegador, com a mesma conta (RN8). É o critério que distingue esta decisão da que guarda no
localStorage. - [ ] CA12. Usuário criado depois da versão atual não recebe marca (RN9).
- [x] CA13. Com o servidor numa versão mais nova que a do navegador, o painel mostra o aviso e o botão de recarregar; com o servidor numa versão mais velha — o rollback —, o painel não mostra nada (RN7).
- [x] CA13a. Com qualquer um dos dois lados não identificado, o painel não mostra o aviso (RN7a): não se afirma "há versão nova" sem saber qual é.
- [x] CA14. O painel abre sem rede: as notas estão no bundle.
- [x] CA15. Em 375px, nenhuma das superfícies novas rola na horizontal, e o painel é o diálogo de tela cheia.
- [x] CA16. O ponto aparece nos dois lugares no mobile — na aba "Mais" e na linha da gaveta.
- [x] CA17. Erro ao carregar mantém o cabeçalho da versão, mostra "Tentar novamente", e não apaga a marca de não lido.
Autorização
- [x] CA18. O
AGENCY_USERvê o ícone, abre o painel e consegue marcar como lido (RN11). - [x] CA19.
PATCH /auth/me/last-seen-versionsem autenticação devolve 401. - [x] CA20.
PATCH /auth/me/last-seen-versiongrava em quem chamou, e não há caminho na API para gravar em outro usuário — não existe rota com id. - [x] CA21.
versionfora do formatoX.Y.Zdevolve 422, e nada é gravado — inclusive quando o valor traz o+Ndo dev-remote. - [x] CA22. Com a versão não identificada, a tela não chama a rota (RN8): não há o que marcar como lido quando não se sabe o que se leu.
- [x] CA23. Usuário criado pelos cinco caminhos nasce com
last_seen_versionpreenchida (RN9), e usuário que já existia antes da migration nasce nula (RN10). Oseed_e2eé a exceção declarada: os usuários da suíte nascem nulos. - [x] CA23a. Rodar o
seed_e2esobre um banco onde um usuário da suíte já marcou como lido devolve o campo a nulo (RN9) — é o que mantém o CA10 repetível no dev-remote. - [x] CA24. Criado num ambiente cuja versão traz o
+Ndo dev-remote, o usuário nasce com só oX.Y.Z; criado semAPP_VERSIONnenhuma, nasce nulo (RN9). É o critério que impede a RN9 de gravar o que a rota da RN8 recusaria com 422. - [x] CA25. Recebendo uma versão mais velha que a guardada, o
PATCHresponde bem e mantém a que já estava — a marca de não lido não ressuscita por causa de uma aba antiga (RN8).
O que ficou por marcar, e por quê. Os critérios abertos são de dois tipos, e nenhum é dívida de implementação:
- Dependem de haver uma tag — CA1 (o número no menu e na gaveta), CA3 (o
+Ndo dev-remote), CA8 e CA9 (o conteúdo da nota no painel), CA10 a CA12 (a marca de não lido). A primeira tag nasce no primeiro/shipdesta feature; até lá o ambiente diz "versão não identificada", que é a RN5a funcionando. O spec e2e cobre os dois ramos e passa a exercer o positivo assim que houver tag, sem mudança nenhuma. - Dependem de um deploy real — CA5, CA5a, CA6, CA6b, CA6c e CA7, que são o comportamento do
/shipe do/tweakde ponta a ponta. O que dava para verificar sem tocar a produção foi verificado: o pré-voo saindo comexit 5/FALTA=commitno estado atual do repositório, orascunho-nota.shmontando a nota e recusando sobrescrever, e odeploy.shrecusandoVERSIONausente.
14. Plano de implementação¶
Na ordem obrigatória do monorepo — o contrato desce da API.
- [x]
apps/api— coluna, schema, rota,APP_VERSION,/health, migration, testes das RN8, RN9 e das negativas de autorização. - [x]
.claude/skills/shipe.claude/skills/tweak— o pré-voo com versão e proposta, o script que monta o rascunho da nota, o gate com o texto, o build arg, a tag depois da verificação, oGIT_COMMITque faltava, e oCLAUDE.mdda raiz com a segunda exceção. - [x]
apps/web/src/versoes/0.1.0.md— a primeira nota, escrita à mão, que é a desta spec. - [x]
apps/web— o catálogo no build, o ícone, a linha da gaveta, o ponto na aba, o painel nos dois viewports. - [x]
apps/e2e— a spec do fluxo.
A ordem entre 1 e 2 importa: o /ship só pode passar VERSION para o build depois de a API saber o que fazer com ela.
15. Decisões em aberto¶
Nenhuma. As quatro decisões de produto foram tomadas na entrevista (numeração, audiência, origem do texto e memória do não lido), e as três que os portões levantaram foram decididas: a nota como arquivo revisado entre duas passadas, o dev-remote com sufixo, e a nota de estreia aparecendo para quem já usa o produto (RN10).
16. Revisão do detalhamento¶
Preenchido pelo laço do /spec new, rubrica .claude/rubrics/detalhamento.md. Registrar todas as rodadas, inclusive as reprovadas.
| Rodada | Critical | Important | Minor | Veredicto |
|---|---|---|---|---|
| 1 | 0 | 9 | 8 | Reprovada |
| 2 | 0 | 6 | 8 | Reprovada |
| 3 | 0 | 6 | 9 | Reprovada |
| 4 | 0 | 1 | 2 | Reprovada |
| 5 | 0 | 1 | 2 | Reprovada |
| 6 | 0 | 1 | 1 | Reprovada |
| 7 | 0 | 2 | 1 | Reprovada |
| 8 | 0 | 1 | 0 | Reprovada |
| 9 | 0 | 0 | 1 | Aprovada |
Rodada 1 — a segurança passou; o que reprovou foi clareza técnica. A seção 10 foi aprovada como está: a superfície de escrita é uma só, age sempre sobre quem chama, e não aceita id — sem id, não há escalada a fechar. Os nove Important foram sobre o resto.
Os três que mais valiam eram fronteiras que a spec não fechava. A primeira: as notas irem no bundle torna impossíveis os estados de "carregando" e de erro que a Parte II descrevia para elas — não há chamada que possa falhar. O que pode falhar é a comparação com o servidor, e agora está dito que o erro é dela e fica confinado à faixa dela; a Parte II foi corrigida junto. A segunda: a data de cada versão não tinha origem — não pode vir do git (o .git não entra na imagem) nem da API (as notas não passam por lá), então o /ship a escreve no arquivo, e o contrato ganhou a segunda chave. A terceira: a RN9 e a RN10 se contradiziam — sem gravar a versão na criação do usuário, "quem é novo" e "quem já existia" ficam os dois com o campo nulo, e os dois critérios que os testam não teriam como distingui-los.
E o encanamento do build não estava listado — que é exatamente como o GIT_COMMIT da 0042 se perdeu. O web de produção não tem args nenhum hoje, o web.Dockerfile não tem ARG, e o build acontece na box. Agora a seção 11 nomeia os quatro arquivos de cada ambiente, mais o rebuild.sh do dev-remote — sem o qual o +N da RN6 não existe e o CA3 falharia por construção, no único ambiente onde o e2e roda.
Duas decisões voltaram para o usuário e foram respondidas. A nota é pública — o /health é público e o bundle é servido na tela de login, e fingir controle onde não há seria pior; virou regra explícita de quem escreve. E a primeira versão é 0.1.0, continuando o número que o FastAPI e o package.json já declaram em vez de recomeçar.
Dos Minor: o campo virou last_seen_version, em inglês como os vizinhos, e saiu do UserResponse — estado de leitura de uma pessoa não é assunto da lista da Equipe; a migration ganhou nome e a ausência de backfill virou decisão escrita; a recusa por nota ausente ganhou exit 5 no pré-voo, que já documenta os seus códigos; a marca some ao abrir o painel, não ao fechar; a RN7 passou a comparar "mais nova" e não "diferente", porque depois de um rollback oferecer "atualizar" mandaria a pessoa para trás; e ficou dito que o push da tag é automático e que o gate do /ship é o pedido que a regra de conduta exige.
Rodada 2 — a segurança segue passando, e o que reprovou foi de novo a cadeia do arquivo. O revisor disse dos cinco critérios da seção 10 que nenhum precisava ser mexido. Três dos seis Important eram a mesma coisa por três ângulos: a nota vivia em spec/versoes/*.md e o bundle consumia um .json gerado — mas o deploy builda origin/main na box, então um arquivo gerado antes do commit dependia de alguém lembrar, e a checagem de "mesmo conteúdo" que eu tinha posto no make preflight copiaria para o bundle público justamente as linhas que a RN4 promete ignorar.
A correção foi tirar o par de cena. A nota passou a ser um arquivo, dentro do apps/web/src/versoes/, lido cru pelo Vite com query: "?raw" — o mesmo lugar e o mesmo desenho do catálogo de tutoriais da 0042. Sem par não há geração, não há sincronia a verificar e não há divergência possível; a seção do preflight saiu junto.
Os outros três eram contradições internas, e as três se resolviam escolhendo um lado. A RN9 mandava gravar "a versão corrente da API" na criação do usuário, mas no dev-remote esse valor carrega o +N da RN6 — que a própria rota da RN8 recusa com 422: agora o servidor normaliza, grava só o X.Y.Z e grava nulo quando não há versão identificável. O PATCH não tinha regra de monotonicidade, então uma aba velha no celular faria a marca de não lido voltar — a gravação passou a só avançar, e isso é o que sustenta a decisão cara desta spec, que é guardar no servidor e não no navegador. E o CA4 cobrava que o version= do FastAPI "deixasse de ser 0.1.0", contra a RN1b que diz que a primeira versão é 0.1.0: o critério agora cobra a origem do valor, não o valor.
Dos Minor: entraram o CA24 e o CA25, que são a prova das duas regras novas.
Rodada 3 — achados novos, e dois deles eram dívida da minha própria correção. A seção 10 passou pela terceira vez, e o revisor listou os cinco critérios um a um dizendo o que conferiu. O que reprovou: na rodada 1 eu troquei a RN7 de "versão diferente" para "versão mais nova" e não mudei o CA13, que continuou mandando construir exatamente o caso que a RN passou a proibir; e escrevi no registro que "a Parte II foi corrigida junto", quando a tabela do painel na 7.1 seguia dizendo que a data vem do servidor. As duas são o sinal descrito no CLAUDE.md — o achado da rodada N citando a linha que a rodada N−1 escreveu.
O maior era um laço que não fechava. A RN1 promete que um major sobe quando você digita, mas a versão confirmada não tinha onde ficar guardada entre as duas passadas do /ship: a primeira gravava a nota com o número proposto sem gate nenhum (o Passo 2 do skill só roda com exit 0), e a segunda propunha o mesmo número de novo. Agora o nome do arquivo é a decisão — achando nota mais nova que a última tag, o pré-voo lê em vez de propor; não achando, o /ship pergunta o número antes de gravar. São dois gates humanos em momentos diferentes: o número na primeira passada, o texto e o deploy na segunda.
E a comparação de versões ganhou a regra do incomparável (RN7a), que faltava justamente nos três ambientes reais: produção sem APP_VERSION, dev-remote com o +N, e a box depois de um rollback — que é o cenário que a RN7 existe para proteger e o único em que ela ficava muda. Compara-se (MAJOR, MINOR, PATCH, N); um lado não identificado cala o aviso.
Fechou junto o sexto caminho de criação de usuário, que a RN9 não enumerava: o seed_e2e, que grava nulo de propósito — sem isso todo usuário da suíte nasceria "já leu" e o CA10 ficaria sem sujeito no único ambiente onde o e2e roda.
Dos Minor: o campo virou last_seen_version também no CA10; Identity e UserResponse passaram a declarar o campo (ele atravessa em runtime, mas não compila sem declaração), e ficou dito que a tela chama setIdentity depois do PATCH em vez de esperar a próxima rota; o init_db.py e o test_authorization_coverage.py entraram na seção 11 — o segundo porque o meta-teste do porteiro reprova até a dispensa ser declarada; o tweak/ saiu, porque esse diretório não existe; a heurística da proposta ganhou o comando que a decide; a data do rascunho virou decisão escrita; a RN1a voltou para antes da RN1b; e o package.json ficou congelado, com o porquê.
Um Important era decisão do usuário, e foi respondido: o push automático da tag contradiz um princípio escrito do CLAUDE.md e do próprio /ship, e a resposta foi manter o push como exceção declarada (RN2a) — a tag nasce depois do deploy dar certo, não carrega commit que já não esteja em origin/main, e o gate humano é o pedido expresso que a regra exige. O SKILL.md do /ship passa a registrar a exceção onde hoje afirma o contrário.
Rodada 4 — um achado, e ele valia a rodada. A RN2 prometia que a tag nunca aponta para deploy quebrado, e a seção 11 a mandava criar dentro do deploy.sh, "ao fim" — que é o ponto que o próprio SKILL.md do /ship documenta como não sendo prova de nada: "build/up sem erro não é prova de que a produção está servindo o código novo e saudável". O script termina em git pull → make up → make migrate → make seed sem fazer uma requisição sequer; a API pode estar em crash-loop e os quatro saem com zero. A tag mudou de passo — nasce no Passo 4, depois do health e da raiz responderem —, e o CA5a passou a nomear esse caso, que ele não cobria: script com êxito e produção fora do ar.
Os dois Minor eram estados de borda do pré-voo, e os dois acontecem já no primeiro uso: não haver tag nenhuma (o intervalo vira o repositório inteiro, e não importa, porque a nota 0.1.0 é escrita à mão e a RN1a manda ler em vez de propor) e haver mais de uma nota não tagueada — aí o comando para e pergunta, em vez de escolher a maior; duas notas soltas querem dizer que um /ship anterior foi abortado depois de gravar o rascunho.
Rodada 5 — uma lacuna de contrato, e ela era do estado que a rodada 4 acabou de criar. A RN1a ganhou na rodada anterior o caso de "mais de uma nota não tagueada", e eu escrevi o comportamento sem lhe dar código de saída — deixando a implementação escolher, e provavelmente reusar o 5. Não serve: o 5 quer dizer "não há nota, vou gravar uma e perguntar o número", e reusá-lo faria o comando gravar um terceiro rascunho exatamente quando o problema é rascunho sobrando. Virou exit 6, que não grava nada, com o CA6a para exercitá-lo.
Dos Minor: a RN7a passou a ter onde morar (components/versao/, e não um hook global — é a única tela que faz a pergunta), e o contrato do /health passou a dizer o valor de version quando não há APP_VERSION: "desconhecido" literal, como o commit já faz, sempre presente e sempre string. É o valor que a RN7a lê como "lado não identificável", e ele estava escrito só na seção 11 — longe de onde quem implementa a comparação vem procurar.
O revisor apontou ainda, fora do escopo dele, que a linha do rodapé da tabela do painel na 7.1 tinha ficado órfã de novo — o mesmo defeito que o portão da experiência registrou ter corrigido na rodada 3. Corrigido.
Rodada 6 — o achado foi sobre repetibilidade, e é o tipo que só aparece na segunda execução. Eu tinha escrito que o seed_e2e "grava nulo de propósito", o que resolve a criação e não resolve nada depois dela: o _upsert_user é idempotente e, no ramo de usuário já existente, reescreve só quatro campos. Como o dev-remote é banco persistente e compartilhado, o primeiro teste que abrisse o painel deixaria adm@e2e.test marcado como lido para sempre, e o CA10 passaria a falhar por régua suja — no único ambiente onde ele roda. O seed agora zera o campo, na criação e no upsert, com o CA23a cobrando isso. É a armadilha da spec 0025 outra vez, e desta vez o teste que ela derrubaria é o do próprio estado.
O Minor era da mesma família de "quem faz o quê": ficou dito que é o components/versao/ que retira o +N antes do PATCH, no mesmo lugar que compara — separar as duas coisas em arquivos diferentes é exatamente como o sufixo escaparia e o dev-remote tomaria 422 para sempre.
Rodada 7 — os dois achados eram de fora do artefato, e nenhuma rodada anterior tinha olhado para lá. O primeiro é o melhor achado do portão inteiro: o /tweak chama o mesmo preflight.sh que a RN12 está mudando (SKILL.md:114) e não deixa arquivo de registro por decisão — então, entregue como estava, esta spec faria o único comando que deploya numa passada tomar exit 5 para sempre. Levado ao usuário, que escolheu a saída que não abre buraco: o /tweak publica versão também, sobe patch e monta a nota da própria mensagem de commit, no gate humano que ele já tem (RN12b). Isentá-lo teria deixado a produção andar sem tag e sem nota — a versão exibida deixando de descrever o que está no ar, que é a Parte I voltando pela porta dos fundos.
O segundo: a RN2a mandava corrigir o SKILL.md do /ship, mas não a raiz do CLAUDE.md, que afirma a mesma coisa de forma mais categórica — "com a única exceção do /tweak". Agora são duas exceções, e a spec diz onde a palavra sai. O Minor era citação de linha do seed_e2e que eu tinha errado.
Rodada 8 — um achado, e era a rodada 4 se repetindo no caminho de código que a rodada 7 acabou de criar. Ao dar ao /tweak a publicação de versão, escrevi que "o deploy dele deixa tag como o do /ship" — frase que admite as duas leituras, e a errada é justamente a que a rodada 4 corrigiu: criar a tag quando o deploy.sh retorna, antes de qualquer prova de que a produção subiu. O /tweak também fecha com curl no /health e na raiz, e é depois dessas duas respostas que a tag nasce. Dois caminhos, uma regra, e o CA6c a cobra.
Nada mais: o revisor conferiu as citações de código uma a uma — os seis pontos de criação de usuário, os quatro campos do ramo de upsert do seed_e2e, os Dockerfiles sem ARG, o _SEM_PORTEIRO_POR_DESENHO — e todas bateram, sem Minor.
Rodada 9 — aprovada. O revisor refez a conferência de todas as citações de código da Parte III, uma a uma, e nenhuma estava errada; percorreu os cinco critérios de segurança e os quatro de clareza dizendo o que conferiu em cada um. O único Minor era ambiguidade de nome, e foi fechado: há dois UserResponse no repositório — o schema Pydantic da API e o tipo TypeScript do web —, e a spec falava dos dois em seções diferentes sem distingui-los, o que fazia a seção 11 parecer contradizer a seção 12.
Resultado: aprovada na rodada 9, sem Critical e sem Important.
Sobre as nove rodadas. O limite do skill é três, e ele foi ultrapassado com autorização do usuário. O que sustenta isso é que nenhuma rodada repetiu achado de rodada anterior — que é o sinal de laço travado que o CLAUDE.md manda parar e perguntar. Aconteceu o contrário: cada rodada olhou uma camada que a anterior não tinha alcançado, e a contagem caiu monotonicamente (9 → 6 → 6 → 1 → 1 → 1 → 2 → 1 → 0). Três reprovações foram dívida da correção anterior — a RN7 mudando sem o CA13 acompanhar, o exit 6 faltando no estado que a rodada 4 criou, a tag do /tweak repetindo o erro de posição que a rodada 4 tinha corrigido para o /ship —, e essas o CLAUDE.md não conta contra o limite. As duas últimas camadas foram as mais caras e as que só apareceram no fim: a interação com outros skills do repositório e com o CLAUDE.md da raiz.
Riscos aceitos: nenhum. Os cinco critérios de segurança passaram em todas as nove rodadas, sem exceção registrada.
17. Revisão da implementação¶
Preenchido pelo /spec implement, rubrica .claude/rubrics/implementacao.md. As revisões de fase (api, web, e2e) vão nas primeiras linhas; a da branch inteira, na última.
| Rodada | Escopo | Critical | Important | Minor | Veredicto |
|---|---|---|---|---|---|
| 1 | apps/api + encanamento | 4 | 1 | 0 | Reprovada |
| 2 | apps/api + encanamento | 1 | 2 | 1 | Reprovada |
| 3 | apps/api + encanamento | 0 | 1 | 2 | Reprovada |
| 4 | apps/web | 0 | 0 | 2 | Aprovada |
| 5 | branch | 1 | 1 | 1 | Reprovada |
| 6 | branch | 1 | 2 | 0 | Reprovada |
| 7 | branch | 1 | 1 | 0 | Reprovada |
| 8 | branch | 1 | 1 | 1 | Reprovada |
| 9 | branch | 0 | 0 | 1 | Aprovada |
A fase de API levou três rodadas, e as três acharam coisas diferentes.
Rodada 1 — os scripts existiam e os documentos que os orquestram, não. Eu tinha escrito preflight.sh, deploy.sh e o gerador de rascunho, e deixado intocados o SKILL.md do /ship, o do /tweak e o CLAUDE.md da raiz — que a seção 11 lista como parte da mesma fase. O efeito seria o mecanismo inteiro inoperante: o skill chamaria o deploy sem VERSION=, e todo deploy embutiria desconhecido para sempre. O revisor também achou um furo real: o pré-voo procurava a nota no disco, e o deploy builda origin/main na box — um arquivo não commitado deixaria passar um deploy sem nota, que é o que a RN12 promete impedir. Agora ele olha os dois e trata a diferença como estado próprio (FALTA=commit).
Rodada 2 — um defeito que teria quebrado toda nota do /tweak. O gerador terminava o intervalo em origin/main, mas o /tweak chama antes do push: naquele instante o commit do ajuste existe só na main local. A nota sairia vazia toda vez, sem erro e sem aviso — cairia no ramo "nenhuma spec, fix ou tweak no intervalo", que é a mensagem de um deploy de infraestrutura. O fim do intervalo passou a ser main. Junto: o CA5b passou a valer antes do deploy (o pré-voo recusa versão já tagueada, em vez de descobrir na hora de criar a tag, com a produção já rebuildada), a base do incremento passou a ser a tag mais alta do repositório e não a que está no ar — número de versão não anda para trás —, e o CA23a ganhou teste.
Rodada 3 — coerência de documento. O SKILL.md do /tweak mandava usar o PROPOSTA= do pré-voo numa seção anterior à única que chamava o pré-voo; quem seguisse na ordem improvisaria o número. Virou dois passos explícitos. Os dois Minor eram o Swagger do /health, que mudou de comportamento sem mudar de descrição, e uma importação fora de ordem.
Rodada 8 — o Critical era eu ter registrado como pronto o que não existia. A rodada 7 descreveu os testes da RN7 e eu escrevi este próprio parágrafo dizendo que eles estavam feitos — mas a edição que os criaria abortou junto com outra no mesmo comando, e o arquivo seguiu com 14 testes e nenhum route.fulfill. O revisor abriu o arquivo e contou. A lição não é sobre o teste, é sobre o registro: eu descrevi o resultado antes de conferir que a escrita tinha acontecido, e o verification-before-completion existe exatamente contra isso.
Escritos de verdade, os dois passaram — e foram provados por reversão. Trocando a regra pela redação intuitiva e errada (version !== VERSAO_DO_BUILD, "diferente → avise") e reconstruindo o dev-remote, os dois ficam vermelhos; restaurada, verdes. É a única cobertura que existe para essa lógica em todo o repositório, porque o apps/web não tem suíte unitária.
O Important era uma promessa que o código não cumpria — e a correção foi na promessa, não no código. A RN12b dizia que o /tweak "sobe patch", sem ressalva; havendo spec mesclada na main e não publicada, ela sobe junto neste deploy, e a proposta vem minor com razão. O número descreve o que vai ao ar, não quem está deployando; forçar patch ali faria a versão mentir sobre o code base, que é o problema da Parte I. O Minor era o meta-teste da RN9 casando por regex e podendo ser cortado por uma chamada aninhada de várias linhas — agora ele conta parênteses, e continua vermelho com a gravação removida (verificado).
Rodada 7 — o mesmo defeito na outra chamada. O /tweak chama o pré-voo duas vezes, e eu tinha posto o PRE_PUSH=1 só na segunda. A primeira — a que busca a PROPOSTA= — caía no mesmo exit 3 que a rodada 6 acabou de diagnosticar, e o texto ao lado dela ainda mandava "siga o que o /ship faria", o que ali significaria empurrar antes de a nota existir e antes do gate humano. É o sinal que o CLAUDE.md descreve: o achado da rodada N citando a linha que a rodada N−1 escreveu.
O Important era um furo que nenhuma rodada tinha olhado: a RN7/RN7a — a comparação com o servidor — não tinha teste nenhum, e como o apps/web não tem suíte unitária, o e2e era o único lugar possível. Agora o /health é interceptado com uma versão mais nova e com uma mais velha. O caso do incomparável tem dentes hoje, e é o mais valioso: com o bundle sem versão, um servidor dizendo 9.9.9 não pode acender o aviso — quem escrevesse a regra como "diferente → avise", que é a redação intuitiva e errada, falha aqui.
Rodada 6 — a correção da rodada 5 estava atrás de uma porta que nunca abria. Eu tinha resolvido o FALTA=commit do /tweak com uma variável que aceitava nota commitada — e o código dela era inalcançável, porque a recusa por "main à frente de origin/main" (RN2 da spec 0030) roda antes, é incondicional, e no /tweak a main está sempre à frente. O conserto certo era mais fundo: o modo virou PRE_PUSH=1 e troca o alvo do pré-voo inteiro para a main local, o que é mais correto e não menos — o relatório passa a descrever exatamente o que o push seguido do deploy vai produzir, em vez de recusar o estado normal daquele fluxo. Ganhou também o simétrico: main atrás do origin recusa, porque ali o push seria rejeitado e o relatório estaria descrevendo o que não iria.
Os dois Important eram de coisa que cada fase achou que era da outra. O Swagger que eu tinha escrito ficou no decorator de /health — rota que nenhum Caddyfile expõe, e que existe para o healthcheck do container — enquanto /api/v1/health, o que o web, o e2e e o /ship chamam, seguia sem descrição; agora as duas leem o mesmo dicionário. E o deploy.sh aceitava VERSION ausente caindo num default mudo: reproduzir em silêncio o defeito que a Parte I descreve, no script que existe para consertá-lo. Agora ele recusa.
Rodada 5 — a revisão de branch achou o que nenhuma fase podia achar, e é o argumento de ela existir. O Critical vive na interação entre preflight.sh e o SKILL.md do /tweak, dois arquivos que fase nenhuma reviu juntos: o /tweak commita a nota e roda o pré-voo antes do push, que é exatamente o estado FALTA=commit — então ele sairia com exit 5 e nunca chegaria a imprimir MIGRATE=. A checagem de migration de carona que o skill promete no gate deixaria de existir em silêncio, e um deploy de tweak poderia subir código novo contra schema velho. O conserto foi um modo explícito (ACEITA_NOTA_LOCAL=1) que aceita nota commitada na main local — porque ela vai no mesmo push que o gate autoriza —, sem afrouxar nada para o /ship.
O Important era o CA16 sem teste: o ponto da aba "Mais" e o da linha da gaveta. Ganhou teste do invariante que o critério protege — os dois leem o mesmo naoLido, e discordar é o defeito. O caso positivo só roda em ambiente com tag, e isso está dito no próprio teste. O Minor era o exit 6 documentado com uma remediação só, tendo duas.
Rodada 4 — a fase de web passou de primeira, com dois Minor que foram corrigidos: um ramo de fullScreen no ícone do desktop que nunca roda (o AppLayout troca de casca pela mesma media query, então o componente é desmontado antes de o isMobile dele virar true), e três desenhos independentes do mesmo ponto de não lido — agora um componente só, com as medidas lidas da custom-sider, que é onde o desenho nasceu. Importa porque no mobile as duas marcas aparecem juntas, e é ali que uma divergência de tamanho se vê.
Rodada 9 — aprovada. O revisor conferiu no arquivo, e não no registro, cada afirmação forte da rodada anterior — foi a instrução, depois de a rodada 8 ter pego exatamente um registro que afirmava algo inexistente. O único Minor foi corrigido: a faixa de "há versão nova" exibia o +N cru do servidor (A versão 1.5.0 +7 já está no ar), misturando linguagem de ambiente numa frase escrita para quem lê de passagem. A comparação continua usando o sufixo (RN7a); a frase, não.
Resultado: aprovada na rodada 9 da revisão de branch.
Sobre as nove rodadas de implementação. As cinco de branch acharam, uma a uma, coisas que fase nenhuma podia achar — e quatro delas viviam na interação entre o preflight.sh e o SKILL.md que o chama, dois arquivos que nenhuma fase revê junto. A mais instrutiva é a oitava: o Critical não era código, era eu ter escrito no registro que os testes da RN7 estavam prontos quando a edição que os criaria havia abortado. O revisor abriu o arquivo e contou. É o verification-before-completion cobrando exatamente onde ele foi desrespeitado — descrevi um resultado antes de conferir que a escrita tinha acontecido.
Defeitos aceitos: nenhum.
18. Registro¶
O vite.config.ts não precisou de mudança, e a seção 11 o listava. Variáveis com prefixo VITE_ já são expostas pelo Vite ao bundle sem define nenhum; acrescentar um seria duplicar o mecanismo. O padrão da RN5a ("não identificada" sem o build arg) mora em src/versoes/index.ts, junto do resto da leitura de versão — que é onde alguém procura.
O ponto de não lido virou componente próprio (components/versao/MarcaNaoLido.tsx), o que o plano não previa. Ele aparece em três lugares e, no mobile, dois deles ficam visíveis ao mesmo tempo — a marca da aba "Mais" e a da linha da gaveta. Escrito três vezes, divergiria em tamanho sem ninguém notar até vê-las lado a lado.
A RN9 ganhou um meta-teste que lê a fonte, além do teste de comportamento. São seis caminhos de criação de usuário em quatro arquivos, e dois deles concedem papel com SQL de PostgreSQL — não rodam contra o SQLite da suíte. Mas o motivo verdadeiro é outro: o campo nulo é um estado legítimo (RN10 depende dele), então um caminho que esquecesse de gravar produziria usuários novos indistinguíveis de usuários antigos, sem erro e com a suíte verde. O meta-teste falha para qualquer User(...) que não decida a versão — inclusive o sétimo caminho, o que ainda não existe.
O dev-remote diz "versão não identificada" até o primeiro /ship, e isso é a RN5a funcionando. A versão de lá é git describe da última tag vX.Y.Z; não há tag no repositório, e a primeira nasce depois do primeiro deploy de produção desta feature. O spec e2e cobre os dois estados em vez de fingir um: com versão identificada, a marca de não lido e o conteúdo da nota; sem, o texto de "não identificada" e o silêncio deliberado da RN8 — a tela não marca como lido o que não sabe nomear. Depois do primeiro ship, o segundo ramo vira o teste de um build sem o argumento.