Pular para conteúdo

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.

  1. 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.
  2. A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
  3. 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.
  4. 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:

  1. ssh root@179.198.118.166 e git -C /opt/garantora rev-parse --short HEAD — devolve um SHA de sete caracteres, c8f9649.
  2. Comparar com git rev-parse --short origin/main na máquina local.
  3. Se forem iguais, a produção está atualizada. Se não, abrir git log para 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?"

  1. Reproduzir o relato, ou pedir mais detalhe.
  2. Procurar em fix/ e spec/ se existe registro daquilo.
  3. Achando, descobrir em que commit entrou (git log --oneline -- fix/NNNN-*).
  4. 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.0 desde 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 o CLAUDE.md registra que hoje não há base instalada a preservar: o único cliente do contrato é o apps/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 /ship já 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.

  1. 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.
  2. 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.
  3. Junto vem o texto do que entrou, montado dos commits e dos registros de spec/, fix/ e tweak que entraram desde a última versão. Ele é rascunho, não decisão.
  4. O rascunho vira arquivo no repositório, e o /ship para 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 /ship de novo.
  5. 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.
  6. O arquivo precisa estar commitado e empurrado antes do deploy — o /ship deploya origin/main e 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.
  7. 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.

  1. A pessoa entra no produto e trabalha normalmente.
  2. 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.md para 2.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 6 e lista os arquivos. Não escolhe a maior nem a mais recente: duas notas não tagueadas querem dizer que um /ship anterior 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 do CLAUDE.md)";
  • CLAUDE.md da 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ó o SKILL.md fosse 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 imprimindo MIGRATE.
  • 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 o deploy.sh sair 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 — passa VERSION como build arg para o web e para a api. Não cria a tag: ela é do Passo 4, pela RN2. E passa o GIT_COMMIT que 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.md e fix/NNNN-*/fix.md desde a última tag, e o grava em apps/web/src/versoes/X.Y.Z.md. Não há diretório tweak/ — o /tweak não deixa arquivo por decisão do CLAUDE.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 curl de fechamento, e não do deploy.sh — a RN2 vale igual aqui. O /tweak já fecha verificando de fora (SKILL.md, seção O gate humano: curl no /health e 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 o deploy.sh retorna —, 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 /tweak e o push da tag pelo /ship (RN2a). Deixar só o SKILL.md corrigido faria o documento que governa o repositório contradizer o comando.
  • A tabela dos quatro comandos passa a dizer que /tweak e /ship publicam 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ço web ganha args com APP_VERSION, e o serviço api ganha APP_VERSION ao lado do GIT_COMMIT que 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 do GIT_COMMIT.
  • infra/prd/Makefile — o make up roda docker compose up -d --build na box, lendo do ambiente do shell remoto: é por ali que APP_VERSION e GIT_COMMIT chegam.
  • infra/dev-remote/* — os mesmos quatro, com o +N da 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 — coluna last_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 no MeResponse, e não no UserResponse. É 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 o MeResponse montado à 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.py e db/init_db.py — os cinco caminhos de criação de usuário gravam a versão corrente, normalizada (RN9). O db/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 — o PATCH /auth/me/last-seen-version entra em _SEM_PORTEIRO_POR_DESENHO com o motivo escrito, ao lado do GET /auth/me que 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.py e main.py — APP_VERSION no mesmo desenho do GIT_COMMIT, e o /health passa a devolvê-la (RN7). O version= do FastAPI passa a vir do APP_VERSION em vez do literal cravado no main.py:484.
  • apps/web/package.json:3 fica congelado, e é decisão. A Parte I o cita junto do main.py como parte do problema, mas os dois não têm o mesmo destino: o version= do FastAPI aparece (no Swagger, e agora no /health), enquanto o do package.json nã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 /ship escrever num arquivo que ele não toca para mais nada, e o pnpm reescrever o lockfile a cada versão. Fica em 0.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. O downgrade derruba a coluna, e aqui ele é simétrico — não há dado a preservar.

apps/web

  • src/versoes/ — as notas em .md e um index.ts que as lê com import.meta.glob (query: "?raw"), ordena por semver e responde as perguntas da tela. Mesmo desenho do src/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 fullScreen em 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 +N da RN6 é retirado antes do PATCH — o corpo leva X.Y.Z puro, 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.ts e providers/auth.ts — last_seen_version: string | null entra na interface Identity e no UserResponse do web (providers/auth.ts:12), que é o tipo TypeScript do que o /auth/me devolve — homônimo do UserResponse da API, mas equivalente ao MeResponse dela. Não é contradição com o que a seção 12 diz: lá o campo entra no MeResponse e fica fora do UserResponse do Pydantic; aqui é o tipo local, em outro app. O valor já atravessa em runtime (o objeto inteiro vai para setIdentity), mas sem o campo declarado user.last_seen_version não compila — o tsc do 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 do PATCH: a tela chama setIdentity com 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-testid em 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_USER vendo 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

GET /api/v1/health                              Público

Ganha version, ao lado do commit que a spec 0042 acrescentou. É o que a RN7 compara.

{ "status": "ok", "db": "ok", "commit": "c8f9649", "version": "1.5.0" }

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.

PATCH /api/v1/auth/me/last-seen-version   → 200 MeResponse    Autenticado

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 +N nos dois viewports (RN6).
  • [x] CA4. GET /health devolve version junto de commit, e o version= do FastAPI passa a vir do APP_VERSION em vez do literal cravado no main.py — o valor continua 0.1.0 enquanto essa for a versão publicada (RN1b); o que muda é de onde ele vem.
  • [ ] CA5. O /ship cria a tag vX.Y.Z e 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.sh sair com zero e a verificação do Passo 4 reprovar (health fora do ar, raiz fora de 200, ou HEAD diferente de origin/main): aí também não há tag (RN2).
  • [x] CA5b. Com vX.Y.Z já existente, o /ship para antes do deploy e diz que aquela versão já foi ao ar (RN2b).

A nota

  • [ ] CA6. O /ship recusa deployar quando há commits desde a última versão e não há nota (RN12), dizendo qual arquivo falta.
  • [ ] CA6b. O /tweak nã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 /tweak cria e empurra a tag depois do curl de fechamento responder, não quando o deploy.sh retorna (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 /ship nã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_version nula, 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_USER vê o ícone, abre o painel e consegue marcar como lido (RN11).
  • [x] CA19. PATCH /auth/me/last-seen-version sem autenticação devolve 401.
  • [x] CA20. PATCH /auth/me/last-seen-version grava em quem chamou, e não há caminho na API para gravar em outro usuário — não existe rota com id.
  • [x] CA21. version fora do formato X.Y.Z devolve 422, e nada é gravado — inclusive quando o valor traz o +N do 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_version preenchida (RN9), e usuário que já existia antes da migration nasce nula (RN10). O seed_e2e é a exceção declarada: os usuários da suíte nascem nulos.
  • [x] CA23a. Rodar o seed_e2e sobre 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 +N do dev-remote, o usuário nasce com só o X.Y.Z; criado sem APP_VERSION nenhuma, 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 PATCH responde 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 +N do 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 /ship desta 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 /ship e do /tweak de ponta a ponta. O que dava para verificar sem tocar a produção foi verificado: o pré-voo saindo com exit 5/FALTA=commit no estado atual do repositório, o rascunho-nota.sh montando a nota e recusando sobrescrever, e o deploy.sh recusando VERSION ausente.

14. Plano de implementação

Na ordem obrigatória do monorepo — o contrato desce da API.

  1. [x] apps/api — coluna, schema, rota, APP_VERSION, /health, migration, testes das RN8, RN9 e das negativas de autorização.
  2. [x] .claude/skills/ship e .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, o GIT_COMMIT que faltava, e o CLAUDE.md da raiz com a segunda exceção.
  3. [x] apps/web/src/versoes/0.1.0.md — a primeira nota, escrita à mão, que é a desta spec.
  4. [x] apps/web — o catálogo no build, o ícone, a linha da gaveta, o ponto na aba, o painel nos dois viewports.
  5. [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.