0044 — O que mudou não cabe onde está sendo contado¶
| Status | CONCLUÍDA |
| Branch | spec/0044-pagina-de-novidades |
| Criada em | 2026-08-23 |
| Apps afetados | api · web · e2e |
| Revisão do problema | aprovada na rodada 1 |
| Revisão da experiência | aprovada na rodada 6 |
| Revisão do detalhamento | aprovada na rodada 1 |
| Revisão da implementação | aprovada na rodada 6 (branch) |
Status:
RASCUNHO→APROVADA→EM IMPLEMENTAÇÃO→CONCLUÍDA
A spec tem três partes, escritas em ordem, e quatro portões — cada um revisado por um subagente de contexto limpo, em até 3 rodadas. A máquina de revisão é do plugin superpowers; a régua é a rubrica correspondente em .claude/rubrics/. Achados vêm classificados: qualquer Critical reprova, Important em aberto reprova salvo decisão registrada do usuário, e Minor vira pendência aceita.
- A Parte I diz qual é o problema e como se convive com ele hoje. Revisada na seção 6 (necessidade × jornada de hoje). É o portão mais barato de todos, e o único que pergunta contra que problema isto está sendo construído.
- A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
- A Parte III diz como se constrói. Revisada na seção 16 (segurança × clareza técnica) — autorização, isolamento, e se sobrou decisão para quem vai codar.
- A implementação é medida contra as três, na seção 17 (aderência × qualidade × testes) — uma revisão por fase, e uma da branch inteira antes de fechar.
Cada parte só se escreve depois que a anterior passou. Descrever tela sobre problema mal enquadrado, ou detalhar endpoint sobre experiência vaga, é o jeito mais caro de descobrir que era outra coisa.
Parte I — O problema¶
Aqui não entra solução: nem tela, nem endpoint, nem botão. Só o que dói, para quem, e o que se faz hoje. Se você já sabe qual é a tela, guarde para a Parte II.
1. Dor¶
Quem volta de uma ausência não consegue saber o que perdeu, nem ir recuperando aos poucos. O produto passou a contar o que muda a cada publicação — mas conta num espaço onde a informação não cabe, com um aviso que não diz quanto, e de um jeito que não deixa rastro nenhum de por onde a pessoa já passou.
São quatro incômodos, e eles têm a mesma raiz: o lugar onde a novidade é contada foi dimensionado para "dar uma olhada de passagem", e o que as pessoas precisam fazer com esse conteúdo é outra coisa.
O aviso diz que há algo, e não diz quanto. É um ponto. Quem passou duas semanas fora não distingue uma publicação de seis, e não tem como ir riscando uma a uma conforme lê: ou leu tudo naquela sentada, ou perdeu a conta. O resultado prático é que a pessoa abre uma vez, não termina, e o aviso some — porque abrir já conta como ler.
O texto não cabe. O espaço é estreito e a leitura é interrompida por qualquer coisa que aconteça na tela atrás. Uma publicação com meia dúzia de itens já força rolagem dentro de uma caixa pequena; várias publicações empilhadas viram uma parede. O conteúdo é escrito para ser lido, e o lugar onde ele aparece pede que seja escaneado.
Não há endereço. Não existe URL para "o que mudou na 1.2.0": não dá para mandar o link a um colega, abrir numa aba ao lado enquanto se confere a tela que mudou, voltar pelo histórico do navegador, nem deixar aberto. Quem quer mostrar a alguém o que entrou precisa pedir que a pessoa clique num ícone e role até achar.
Navegar entre publicações é rolar. As anteriores aparecem empilhadas abaixo da atual, cinco por vez, com um "ver mais" que carrega as próximas cinco. Não há como pular direto a uma publicação específica, nem saber em que ponto da lista se está. Procurar quando uma mudança entrou é rolar até encontrar.
O custo de conviver com isso é conhecido, e ele não é sobre leitura — é sobre suporte. A pergunta que originou tudo isto ("o que mudou?", "que versão você está usando?") continua chegando por WhatsApp, e continua sendo respondida a mão, porque o lugar que deveria responder é difícil de apontar: não há link a mandar, e "clica no ícone da estrelinha e rola" não é uma resposta que se escreve. Enquanto isso, quem escreve as notas gasta o mesmo trabalho de sempre e alcança menos gente.
2. Quem sente¶
Todos os papéis, sem exceção, e é por desenho. A publicação não tem porteiro (spec 0043, RN11): ADMIN_GLOBAL, ADMIN_TENANT, ANALYST, OPERATOR, VIEWER e o AGENCY_USER da imobiliária parceira leem o mesmo texto. Não há papel que sinta mais por ser papel — o que separa quem sente de quem não sente é o tempo fora.
Quem sente primeiro é quem volta. Alguém que abre o produto todo dia lê uma publicação por vez e o ponto basta. Quem passou uma semana em campo, ou entrou de férias, volta com várias por ler e é exatamente aí que o ponto para de informar e a caixa estreita para de caber. É a mesma pessoa que mais precisa saber o que mudou, porque é a que voltou para uma tela diferente da que deixou.
E quem sente logo depois é quem dá suporte. Hoje somos nós; quando a operação crescer, será quem atende. Essa pessoa precisa apontar — "está aqui, ó" — e não tem para onde apontar.
Não há volume a medir, e isso é limite declarado. O produto tem uma garantidora em ativação e nenhuma operação de rotina; a funcionalidade que este problema descreve está no ar há horas, e existe uma publicação (a 0.1.0). Qualquer número de "quantas pessoas por semana" seria inventado. O que sustenta o alcance é o desenho, não a estatística: o aviso aparece em toda tela do produto, para todo papel, e cresce em relevância a cada publicação — o problema não é raro, é novo.
3. A jornada de hoje¶
Esta é a jornada mais bem conhecida que uma spec deste repositório já descreveu, e vale dizer por quê: o caminho de hoje foi construído há poucas horas, pela spec 0043, e está no ar em produção na versão 0.1.0. Não é reconstrução de memória — é leitura do que existe.
O caminho, do gatilho ao desfecho¶
- O gatilho é uma publicação. Alguém roda o
/ship, escreve a nota, e o deploy marca a tag. A partir daí, todo mundo que abrir o produto está numa versão nova. - A pessoa entra no produto e trabalha. Nada interrompe, nada abre sozinho — foi decisão da 0043, e continua certa.
- Ela repara num ponto ao lado do ícone de novidades, no canto superior direito (no celular, numa linha da gaveta e num ponto na aba "Mais"). O ponto diz que há algo. Não diz o quê nem quanto.
- Ela clica. Abre um painel ancorado no ícone, com cerca de 380px de largura e altura limitada a 70% da tela, rolando por dentro. No celular, um diálogo de tela cheia.
- No mesmo instante do clique, a leitura é dada como feita. O ponto some. Se ela fechar sem ler, ele não volta — e é o desfecho correto para "uma publicação por vez", que era a premissa.
- Ela lê a publicação atual, e abaixo as anteriores empilhadas, cinco por vez, com um "ver mais" que traz as próximas cinco.
- Ela fecha clicando fora, e volta ao que estava fazendo.
Onde exatamente quebra¶
No passo 5, quando há mais de uma publicação por ler. O registro é de uma versão só — "a última que você viu" —, então não existe estado intermediário: não há "li duas das quatro". Abrir marca tudo. Quem foi interrompido no meio perde o rastro e não tem como recuperá-lo.
No passo 6, quando a publicação é longa ou são várias. A caixa é estreita e a leitura compete com a tela atrás dela. Um clique fora — que é o gesto que fecha — descarta tudo, e voltar significa clicar no ícone de novo e rolar de novo até onde estava.
Fora do sistema, no passo que ninguém vê: quem quer mostrar a mudança a outra pessoa manda um print no WhatsApp, ou descreve por escrito o que a nota já dizia. É o retrabalho que a nota existia para eliminar, e ele sobrevive porque não há link.
O que já se tentou¶
A tentativa é a própria spec 0043, e ela não falhou: entregou o que era mais difícil e que continua valendo — o número de versão que descreve o código no ar, a tag no git, a nota como arquivo revisado antes de publicar, e o registro de leitura guardado na pessoa e não no navegador (quem lê no computador não vê o aviso de novo no celular).
O que ela escolheu, e que este problema questiona, foi a superfície. A 0043 decidiu explicitamente por um painel ancorado em vez de uma tela, e escreveu o motivo: "o que se lê ali é consulta, não decisão", e uma tela dedicada a histórico de versões estava no fora de escopo dela. A decisão era coerente com a premissa de então — uma publicação por vez, lida de passagem. Bastou existir a primeira publicação para a premissa aparecer como premissa.
Duas outras coisas da 0043 já foram tentadas e não devem ser refeitas: o ponto em vez de número, que ela justificou dizendo que "quantas versões a pessoa deixou de ler não é uma informação que mude o que ela vai fazer" — e que é exatamente o que se descobriu ser falso; e o "ver mais" de cinco em cinco, que existe porque num painel ancorado a altura é o recurso escasso, e some junto com o painel.
4. Resultado esperado¶
Passa a ser possível ler as novidades em várias sentadas, sabendo o que falta. Cada publicação tem estado próprio de lida ou não lida, o aviso diz quantas faltam, e ele cai de uma em uma conforme a pessoa lê. Interromper deixa de custar o rastro.
Passa a ser possível apontar. Uma publicação específica ganha endereço próprio: dá para mandar o link a alguém, abrir numa aba ao lado da tela que mudou, e voltar pelo histórico do navegador.
Passa a ser possível dizer "já vi tudo" numa ação só, para quem voltou de muito tempo fora e não quer ler o histórico inteiro — sem que isso seja o efeito colateral de ter aberto a tela por engano.
Como se sabe que resolveu, sem instrumentação:
- Alguém com três publicações por ler abre uma, sai, volta depois, e o aviso mostra duas — hoje mostraria nada.
- Uma publicação específica é alcançável por um link colado noutro lugar, sem instrução de "clique no ícone e role".
- Perguntar "o que mudou?" passa a se responder com um link, e não com um print.
- Quem entra no produto hoje continua sem aviso nenhum — a linha de base de quem chega é a versão do dia em que chegou, como já é hoje, e um contador que nasce em 40 seria um contador que se aprende a ignorar.
5. Fora de escopo¶
A publicação em si não muda. Como a versão é decidida, como a tag é criada, o formato do arquivo de nota, o /ship e o /tweak: tudo isso é da 0043 e fica como está. Esta spec mexe em como se lê, não em como se publica.
A nota continua sendo escrita à mão, no mesmo contrato de uma linha. Nada de editor, de rich text, de imagem ou de vídeo na nota. O contrato (- vira item, o resto é ignorado) foi decidido na 0043 porque o conteúdo cabe em frases, e continua cabendo.
Não entra busca nem filtro na lista de publicações. Com uma publicação, buscar é absurdo; a decisão de quando isso passa a fazer falta é de outra spec, com o histórico já existindo.
Não entra notificação por e-mail nem push. A leitura é opcional e não interrompe — é a decisão da 0043 que separa este aviso do banner do PWA, e ela continua valendo.
Não entra "marcar como não lido". Desfazer uma leitura é reversão de um estado que só informa; quem quiser reler abre de novo, e o conteúdo está lá.
Não entra saber quem leu o quê do ponto de vista de quem publica. O registro é para a pessoa se orientar, não para alguém medir audiência — e transformá-lo em relatório levantaria pergunta de privacidade que ninguém fez.
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 | 0 | 1 | Aprovada |
Rodada 1 — aprovada, e o que ajudou foi a jornada de hoje ser conferível. O revisor cruzou cada afirmação da seção 3 com o código em apps/web/src/components/versao/ e com o texto da 0043 — os 380px do painel, o maxHeight: 70vh, o POR_LEVA = 5, a leitura marcada no abrir e não no fechar, as duas citações diretas — e todas bateram. É a diferença entre descrever um caminho que existe há horas e reconstruir um de memória: não houve o que imaginar.
O único Minor foi solução vazando: a seção 4 dizia "existe endereço para a página", nomeando o mecanismo em vez do efeito. Virou "uma publicação específica ganha endereço próprio" — a página é assunto da Parte II.
Resultado: aprovada na rodada 1.
Pendências aceitas: nenhuma.
Parte II — A experiência¶
Não preencher antes da revisão da seção 6 estar aprovada.
7. Jornada pretendida¶
O ponto de entrada não muda de lugar, muda de destino. Continua sendo o ícone da barra superior, no desktop, e a linha da gaveta, no celular — os dois já existem desde a 0043, e mover o ponto de entrada custaria a quem acabou de aprender onde ele fica. O que muda é o que acontece ao tocar: em vez de abrir uma caixa por cima da tela, leva a uma tela.
E o aviso passa a dizer quanto. Onde havia um ponto passa a haver um número: quantas publicações esta pessoa ainda não abriu. Sem nada por ler, o ícone é só um ícone.
A jornada, do gatilho ao desfecho¶
- A pessoa entra no produto e trabalha. Nada interrompe — continua valendo, e é o que separa este aviso do banner do PWA, que interrompe porque precisa de uma ação.
- Ela repara que o ícone traz 3. Sabe, sem tocar em nada, que perdeu três publicações.
- Toca, e chega à tela de novidades. A publicação mais antiga que ela ainda não leu já está aberta: é por onde a história faz sentido, e é a que ela teria escolhido.
- Ao lado, a lista de todas as publicações, com as não lidas marcadas. O ícone agora mostra 2 — aquela que ela está lendo acabou de contar como lida.
- Ela lê, escolhe a seguinte na lista, lê, e o contador vai caindo de uma em uma.
- Ela é interrompida e sai. Volta duas horas depois, e o ícone mostra 1 — o que ficou por ler continua por ler.
- Voltando, o que abre é de novo a mais antiga não lida. Ela termina, e o ícone fica sem número.
A jornada de quem voltou de muito tempo fora tem um atalho. Com dezenove publicações por ler, ninguém vai abrir dezenove. Há uma ação visível na tela — "marcar todas como lidas" — que zera o contador de uma vez. Ela é um botão que a pessoa aperta de propósito, e não o efeito colateral de ter aberto a tela: quem chegou por engano não perde o rastro.
A jornada de quem quer mostrar algo a alguém termina num link. Cada publicação tem endereço próprio; colado no WhatsApp, ele abre a tela já naquela publicação.
Chegar por link é ler, e conta como ler — só aquela. Abrir os detalhes de uma publicação a marca como lida, por qualquer caminho: escolhendo no índice ou caindo direto pelo endereço dela. O que não acontece é arrastar as outras junto: as anteriores que continuavam por ler continuam por ler, e o contador cai exatamente um.
A consequência é que a ordem de leitura pode ficar salteada, e isso é aceito. Alguém com três por ler que receba o link da mais nova fica com as duas mais antigas ainda marcadas — o índice as mostra assim, e voltando à tela pelo ícone é a mais antiga delas que abre. É a diferença entre um registro por publicação e o "última que você viu" de hoje: o estado é de cada uma, não de uma linha do tempo, e é isso que permite ler fora de ordem sem perder a conta.
Custo de uso¶
Zero digitação, e nenhuma pergunta. Não há campo, não há busca, não há filtro. O que o sistema já sabe e por isso não pergunta: qual versão este navegador está rodando, quais publicações existem, e quais delas esta pessoa já abriu.
O que a pessoa precisa saber para usar: nada. O número no ícone é o convite inteiro, e a lista responde "onde estou" sem que ninguém explique.
O que sai de cena¶
O painel ancorado da 0043 deixa de existir, nas duas formas — o popover do desktop e o diálogo de tela cheia do celular. Manter os dois criaria duas maneiras de ler a mesma coisa, com dois estados de leitura possíveis, e a pessoa descobriria a diferença no pior momento: aquele em que uma delas marcou como lido e a outra não.
O aviso de "há versão mais nova no ar" acompanha a mudança e vira faixa da tela (decisão do usuário, 2026-08-23). Ele existe porque a versão que este navegador está rodando e a que o servidor está servindo divergem numa janela real — quem está com a aba aberta continua no código antigo até recarregar —, e porque o banner do PWA tem um "Depois" que o esconde: sem esta faixa, quem usou aquele botão fica sem caminho nenhum. A 0043 construiu isso de propósito, e não é o tipo de coisa que se descarta ao trocar de superfície.
O que ele custa agora é uma navegação, e isso é aceito: ele deixa de estar a um clique do ícone e passa a estar na tela de novidades. Em compensação ganha espaço para dizer qual versão está no ar, e fica ao lado do conteúdo que explica o que aquela versão trouxe — que é a pergunta seguinte de quem lê "há versão mais nova".
A versão continua legível fora da tela de novidades, no menu do avatar (desktop) e na linha da gaveta (celular). Aquilo responde a "qual versão você está usando?", que é pergunta de suporte e não de leitura, e não deve custar uma navegação.
O que vale nos dois viewports¶
Três regras atravessam desktop e celular, e ficam escritas aqui de propósito. Elas já divergiram três vezes durante a revisão desta Parte II — a correção entrava numa subseção e não na outra —, e o remédio para isso não é lembrar melhor: é a regra ter um lugar só.
1. O endereço acompanha a publicação aberta, sem empilhar histórico. A barra de endereços passa a nomear a publicação que está na tela, seja qual for o caminho: clicar no índice à direita, escolher no seletor do celular, ou chegar por um link colado. Copiar o endereço funciona a qualquer momento, sem precisar ter chegado por ele. Mas o "voltar" — o botão do navegador ou o gesto do sistema — sai da tela, e não caminha para trás pelas publicações que a pessoa acabou de ler. É o desfecho que corresponde ao gesto: quem aperta voltar depois de ler quer retomar o trabalho, não desfazer a leitura passo a passo. Ler cinco publicações não deve custar cinco toques em "voltar" para sair de onde se entrou com um.
2. Abrir os detalhes marca aquela publicação como lida — só aquela, por qualquer caminho. Índice, seletor ou link direto dão no mesmo. As demais mantêm o estado delas.
3. Enquanto o estado de leitura não chegou, nenhum distintivo aparece. Vale para o ícone do desktop, para a linha da gaveta e para o ponto da aba "Mais" ao mesmo tempo — nunca um número provisório, e nunca uma superfície dizendo "há algo" enquanto a outra ainda não sabe.
7.1 Desktop¶
O ícone, na barra superior. Mesmo lugar de hoje, à esquerda do alternador de tema, com o mesmo rótulo acessível "Novidades da versão". A diferença é o distintivo: um número onde havia um ponto — é o mesmo distintivo numérico que a navegação lateral já usa para contar análises pendentes, e não um desenho novo. Passando de nove, mostra 9+: a partir dali o que a pessoa precisa saber é "muitas", e o número exato só ocuparia largura.
Clicar navega. Não abre nada por cima; a barra de endereços muda, e o botão "voltar" do navegador funciona como em qualquer outra tela do produto.
A tela tem duas colunas, e a lista fica à direita.
| Onde | O quê |
|---|---|
| Cabeçalho | o título Novidades, e a ação "Marcar todas como lidas" |
| Faixa | quando o servidor tem versão mais nova que a deste navegador: o aviso e o botão de recarregar. Some quando não há divergência |
| Coluna principal (esquerda) | a publicação aberta: número, data, e os itens dela |
| Coluna lateral (direita) | a lista de todas as publicações, da mais nova para a mais antiga |
A lista à direita é o índice, e cada linha traz três coisas: o número da versão, a data, e a marca de não lida. A linha da publicação aberta fica destacada, para a pessoa saber onde está sem contar de cima.
Por que à direita, e não à esquerda. À esquerda o app já tem uma coluna — a navegação principal —, e duas colunas de navegação lado a lado disputariam o mesmo papel. Além disso, a tela de tutorial já resolve exatamente esta forma (conteúdo à esquerda, índice à direita, empilhando abaixo do breakpoint largo), e repetir o que existe é mais barato que inventar.
O que fica visível ao mesmo tempo: a publicação inteira e o índice inteiro, enquanto houver poucas. Passando disso, quem rola é a lista, dentro da própria coluna — a publicação aberta não sai da tela por causa do tamanho do histórico.
Zero passo até concluir. Ler é o ato; não há confirmação, não há salvar, não há desfazer. A única ação da tela é o "marcar todas como lidas", e ela é opcional.
7.2 Mobile (375px)¶
A barra superior continua sem 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 é a linha da gaveta, que já existe desde a 0043, e ela ganha o número no lugar do ponto:
Versão 1.5.0 · novidades 3
O número aqui capa em 9+ como o do ícone, mesmo cabendo mais: a linha tem largura de sobra, mas as três superfícies dizem a mesma coisa e divergir no formato faria a pessoa achar que são contagens diferentes. Acima de nove, o que ela precisa saber é "muitas" — e o número exato está na tela seguinte, no índice.
Tocar fecha a gaveta e navega para a tela — não abre diálogo. O ponto na aba "Mais" continua existindo, porque é o que avisa quem não abriu a gaveta; ele segue sendo ponto, e não número: a barra tem quatro abas dividindo 375px em partes iguais (três de módulo mais a "Mais"), o que dá cerca de 94px por aba para ícone, rótulo e distintivo. Um número de dois dígitos ali disputa espaço com a palavra "Mais", e a pergunta que se faz naquela aba é "tem algo lá dentro?", não "quanto?" — o quanto está a um toque de distância, na linha.
A faixa de "há versão mais nova" fica acima do seletor, logo abaixo do cabeçalho da tela — a mesma posição relativa do desktop, onde ela vem acima do conteúdo. Acima, e não entre o seletor e os detalhes: ela fala da tela inteira, não da publicação escolhida, e enfiada no meio pareceria pertencer àquela publicação. Quando aparece, empurra seletor e conteúdo para baixo; quando não há divergência, não ocupa altura nenhuma.
As duas colunas viram uma, e o índice vira um seletor recolhido no topo. É a mudança que a tela pede em 375px: uma coluna lateral de 300px não cabe, e empilhar a lista inteira acima do conteúdo faria a pessoa rolar por todo o histórico antes de chegar ao texto que veio ler.
O seletor é uma linha de largura cheia, com o alvo de toque de sempre:
Versão 1.5.0 · 23/08/2026 ⌄
Tocando, ele expande para baixo com a lista das publicações — as não lidas marcadas —, e o conteúdo desce junto. Escolhendo uma, ele recolhe e mostra os detalhes dela logo abaixo. O componente é o MobileDetailSection de components/shared/, com a prop colapsavel — a tela de detalhe da garantia o usa em sete seções, e o Collapse do MUI mora dentro dele. Não é padrão novo, e o lugar de procurar é o compartilhado, não a pasta da tela.
A lista expandida tem teto e rola por dentro. Ela ocupa no máximo cerca de metade da altura da tela e rola dentro de si mesma — não empurra a página sem limite. É o mesmo raciocínio do desktop, onde quem rola é a coluna do índice: com vinte publicações, uma lista sem teto faria a pessoa rolar o histórico inteiro para reencontrar o texto que veio ler, que é exatamente o incômodo que esta spec existe para eliminar. Recolhida, ela ocupa uma linha; expandida, nunca toma a tela toda.
Com uma publicação só, o seletor perde a seta e deixa de ser tocável — vira a linha que diz qual publicação está aberta. Um controle de escolha que abre uma lista de um item convida a um toque que não leva a lugar nenhum. No desktop isso não se aplica: uma coluna com uma linha continua sendo o índice, e não promete escolha que não existe.
"Marcar todas como lidas" fica no cabeçalho da tela, e é a ação que sobrevive em 375px — é a única da tela, então não disputa espaço com nada. Ela não vira ícone sem rótulo: um ícone solitário para uma ação destrutiva-de-estado seria adivinhação, e o texto cabe.
Nada desta feature deixa de existir no celular. Ler a publicação, escolher outra, ver o que falta e marcar tudo como lido — os quatro são completáveis em 375px.
Nada rola na horizontal. O texto das publicações é prosa curta em lista; o seletor e as linhas do índice ocupam a largura disponível e quebram.
O PWA não é afetado por assets, mas ganha uma rota nova — que precisa se comportar como as demais na tela instalada: abrir, voltar e ser alcançável offline no que vem do código carregado.
7.3 Estados¶
Carregando — e ele é parcial, de propósito. O texto das publicações vem embutido no código carregado (decisão da 0043), então o índice e o conteúdo aparecem de imediato. O que precisa ser buscado é o que esta pessoa já leu, que chega junto da identidade. Até chegar, a tela mostra o conteúdo sem as marcas de não lido, e o distintivo não aparece — nunca um número provisório: um contador que muda de 0 para 3 depois de dois segundos é pior que um contador que demora a aparecer.
É a regra 3 de O que vale nos dois viewports, e o que ela acrescenta aqui é a forma: nenhuma das três superfícies muda de tamanho ao receber o distintivo — ele aparece sobre o ícone, e a linha da gaveta já reserva o espaço dele.
Nada por ler — e são duas situações, com textos diferentes. As duas mostram a mesma tela: ícone sem número, publicação mais recente aberta, índice sem marca nenhuma, e o "marcar todas como lidas" desabilitado em vez de escondido — sumir com o botão faria a tela mudar de forma entre uma visita e outra. O que muda é o que está escrito ao lado dele:
- Quem leu tudo — "você já leu todas". É o caso comum de quem usa o produto todo dia.
- Quem acabou de chegar — "nada novo desde que você entrou". Esta pessoa não leu nada, e dizer que leu seria mentira; ela simplesmente não estava aqui quando as publicações anteriores saíram, e a linha de base dela é a versão do dia em que entrou (Parte I, seção 4). Escrever "você já leu todas" para quem nunca abriu a tela é o tipo de texto que faz a pessoa desconfiar do resto.
Uma publicação só, e ela é a atual. É o estado de hoje (só existe a 0.1.0). O índice mostra uma linha, e ela é a que está aberta. Não há nada a esconder aqui: uma lista de um item é uma lista, e a tela não precisa de forma especial para isso.
Publicação sem item nenhum. O contrato permite uma nota só com comentários — um deploy que só mexeu em infraestrutura. A tela diz que esta versão não trouxe mudança visível, e a publicação conta como lida normalmente ao ser aberta.
Versão do produto não identificada. Acontece quando o build não recebeu a versão (decisão da 0043: dizer que não sabe em vez de exibir número que pode estar errado). A tela continua inteira — o histórico de publicações não depende de saber em qual delas este navegador está —, e o que muda é que nenhuma publicação aparece destacada como "a atual".
Servidor com versão mais nova. A faixa aparece acima do conteúdo, diz qual versão está no ar, e traz o botão de recarregar — é a única ação da tela além do "marcar todas como lidas", e ela não acontece sozinha. Mais nova, e não apenas diferente: depois de uma volta atrás 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.
A comparação não é comparável. Quando um dos dois lados não tem versão identificável, a faixa não aparece — afirmar "há uma versão mais nova" sem saber qual é a de um dos lados é chute, e o silêncio é o comportamento honesto.
Erro ao comparar com o servidor. A comparação é a única parte da tela que depende de rede além do estado de leitura, e é a única que pode falhar por conta dela. O erro fica confinado à faixa, com "tentar novamente"; o resto da tela continua inteiro, porque nunca dependeu daquilo.
Erro ao registrar a leitura. A pessoa abriu a publicação e a gravação falhou. O texto continua na tela — ela veio para ler, e leu. O que não pode acontecer é a marca sumir: ela fica como estava, e a tela diz numa linha discreta que não conseguiu registrar, com "tentar novamente". Apagar a marca localmente faria a publicação sumir da conta sem ter sido registrada, e ela não voltaria no aparelho seguinte.
Erro ao marcar todas como lidas. Mesmo desenho, e mais visível porque foi uma ação deliberada: o botão volta ao estado normal, as marcas ficam como estavam, e a mensagem diz que não deu — nunca um contador zerado na tela e cheio no servidor.
Sem permissão: não existe. A tela é para todo mundo que consegue entrar, e a consequência já foi aceita na 0043 — a publicação é lida também pela imobiliária parceira, e quem escreve precisa saber disso. O painel do SaaS admin herda a mesma casca e portanto o mesmo ícone, o que é o desfecho certo: quem administra a plataforma é quem mais precisa saber que versão está no ar.
Endereço de publicação que não existe. Alguém colou um link de uma versão que foi renomeada, ou digitou errado. A tela se comporta exatamente como se a pessoa tivesse entrado pelo ícone — abre a publicação mais antiga que ela ainda não leu — e acrescenta uma linha dizendo que aquela publicação não foi encontrada. Não é tela de erro: o que existe para ver continua todo ali.
Cair aqui não abre exceção na regra 2, e é por isso que o desenho é este. A tentação seria dizer "o fallback não marca como lida, porque a pessoa não escolheu aquela publicação" — e aí passariam a existir dois tipos de "estar com os detalhes abertos", um que conta e outro que não, indistinguíveis na tela. O que a pessoa vê é a tela de novidades no estado normal dela; a publicação que abriu conta como lida como contaria de qualquer outro jeito. A regra 2 não tem exceção, e isso vale mais que economizar uma leitura de quem clicou num link quebrado.
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 | 2 | 2 | Reprovada |
| 2 | 0 | 2 | 1 | Reprovada |
| 3 | 0 | 2 | 1 | Reprovada |
| 4 | 0 | 1 | 1 | Reprovada |
| 5 | 0 | 1 | 0 | Reprovada |
| 6 | 0 | 0 | 1 | Aprovada |
Rodada 1 — o que reprovou foi uma funcionalidade viva sumindo em silêncio. A Parte II dizia o que saía de cena — o painel ancorado — e não dizia nada sobre a faixa de "há versão mais nova, recarregue", que vivia dentro dele. Ela não estava na tela nova, não estava no fora de escopo, e não estava nos estados: teria sido descartada sem que ninguém tivesse decidido descartar. É a pior forma de perder uma decisão, porque a 0043 a construiu justificando por escrito — o "Depois" do banner do PWA esconde o outro caminho. Levada ao usuário, ela vai para a página, como faixa acima do conteúdo, e os estados dela entraram junto: mais nova e não apenas diferente, silêncio quando um dos lados não é comparável, e erro confinado à própria faixa.
O segundo Important era um texto que mentia: "nada por ler" mostrava "você já leu todas" também para quem acabou de chegar e nunca leu nada. A Parte I já decide que a linha de base de quem entra é o dia em que entrou; faltava a Parte II dizer que são duas situações com o mesmo desenho e textos diferentes.
Dos Minor: eu tinha escrito que a aba do mobile tem 75px, e o revisor foi ao código medir — são quatro abas dividindo 375px, cerca de 94px cada. A conclusão sobrevive, o número não; e ficou dito que a regra de "nunca um número provisório" vale para as três superfícies ao mesmo tempo, não só para o ícone do desktop.
Rodada 2 — os dois achados eram buracos que a própria spec abria. O primeiro: ela prometia link por publicação e não dizia o que o link faz com o estado de leitura. Como o contador é o centro desta spec, era o tipo de ambiguidade que se implementa errado sem ninguém perceber. A regra já estava decidida — abrir os detalhes marca —, faltava dizer que vale por qualquer caminho e que só marca aquela: as anteriores continuam por ler, e a ordem pode ficar salteada, que é justamente o que um registro por publicação permite e o "última que você viu" de hoje não permitia.
O segundo: o desktop resolvia o índice crescendo ("quem rola é a lista, dentro da própria coluna") e o mobile não tinha o equivalente — a lista expandida cresceria sem teto, reintroduzindo num componente novo o problema que a spec inteira existe para eliminar. Agora ela tem altura máxima e rola por dentro.
O Minor foi eu citar um precedente que não existe: escrevi que o Collapse é usado "para os grupos da navegação lateral", e o revisor foi conferir — os rótulos de seção da sider são estáticos e não clicáveis, por decisão registrada no CLAUDE.md do web. O precedente real (a seção recolhível da tela de garantia) sustenta a afirmação sozinho, e o inventado saiu.
Rodada 3 — os dois achados eram dívida das correções anteriores, e o sinal é literal. A rodada 1 acrescentou a faixa de "há versão mais nova" e a descreveu no desktop e nos estados; a rodada 3 apontou que a 7.2 nunca a menciona — a correção não atravessou o viewport. A rodada 2 escreveu que abrir uma publicação por link marca só aquela; a rodada 3 apontou que a mesma pergunta, aplicada a escolher no índice, seguia sem resposta: cada escolha empilha histórico ou troca o endereço no lugar?
As duas fechadas: a faixa vai acima do seletor, logo abaixo do cabeçalho — mesma posição relativa do desktop, e acima porque ela fala da tela e não da publicação escolhida. E trocar de publicação troca o endereço sem empilhar: o link continua copiável a qualquer momento, e o "voltar" do navegador sai da tela em vez de caminhar para trás pelas publicações. Ler cinco não deve custar cinco toques em "voltar" para sair de onde se entrou com um.
O Minor era o seletor do mobile mantendo a seta de expandir com uma publicação só — agora ele perde a seta e deixa de ser tocável, porque um controle que abre uma lista de um item convida a um toque que não leva a lugar nenhum.
Rodada 6 — aprovada, e o único Minor estava errado. O revisor apontou que o precedente do Collapse "na garantia" não existia — grepou Collapse em pages/guarantees/ e não achou nada. Conferi: o precedente existe, e o grep passou por cima dele porque o Collapse mora no MobileDetailSection de components/shared/, que a tela da garantia usa em sete seções pela prop colapsavel. A rodada 5 tinha verificado isso corretamente.
O conserto não foi discutir com o revisor, e sim tirar a ambiguidade que permitiu o engano: o texto passa a nomear o componente em vez da tela, e diz onde procurar. Uma citação que depende de o leitor saber que o comportamento é herdado de um compartilhado é uma citação que vai ser conferida errado de novo.
Rodada 5 — um caminho que ninguém tinha olhado porque não é uma escolha. A regra 2 ("abrir os detalhes marca aquela publicação") cobria três caminhos deliberados — índice, seletor, link. O estado de link quebrado descrevia um quarto: a tela abrindo a mais recente como recuperação. Aquilo contava como leitura? As duas respostas eram defensáveis, e a errada consome silenciosamente uma publicação do contador de quem só clicou num link torto.
A saída foi remover o caso especial em vez de especificá-lo. O link quebrado passa a levar exatamente ao estado de quem entrou pelo ícone — a mais antiga não lida —, mais uma linha dizendo que a publicação pedida não existe. Assim não passam a existir dois tipos de "estar com os detalhes abertos", um que conta e outro que não, indistinguíveis na tela: a regra 2 fica sem exceção, e isso vale mais que economizar uma leitura.
Rodada 4 — a mesma classe pela terceira vez, e aí o conserto deixou de ser a instância. A regra de endereço que a rodada 3 escreveu entrou na 7.1 e não na 7.2. Antes dela, a faixa de versão nova tinha feito o mesmo. Três correções seguidas atravessando um viewport e não o outro não é desatenção que se resolve prestando mais atenção: é a estrutura da Parte II convidando ao erro, porque uma regra que vale para os dois cabia em dois lugares.
Então o que mudou não foi a frase, foi onde ela mora: a Parte II ganhou "O que vale nos dois viewports", antes da 7.1, com as três regras que atravessam — endereço, o que marca como lido, e o distintivo durante o carregamento. As subseções descrevem o que é próprio de cada tela; o que é comum não tem mais como divergir.
O Minor era o número da linha da gaveta sem teto declarado: ele capa em 9+ como o do ícone, mesmo cabendo mais, porque três superfícies dizendo a mesma coisa em formatos diferentes fazem parecer contagens diferentes.
Sobre as rodadas além da terceira. O limite do skill é três, e as duas reprovações desta rodada citam linhas que as rodadas anteriores escreveram — o caso que o CLAUDE.md manda isolar e não contar contra o limite, porque o que se paga é dívida de verificação, não desatenção. Nenhum achado repetiu: cada rodada olhou uma camada que a anterior não tinha alcançado.
Resultado: aprovada na rodada 6.
Sobre as seis rodadas. Nenhuma repetiu achado de outra, e a contagem caiu de forma monotônica (2 → 2 → 2 → 1 → 1 → 0 Important). Três reprovações foram da mesma família e pela mesma causa estrutural: uma regra que valia para os dois viewports cabia em dois lugares, e a correção entrava num só. Isso parou quando o conserto deixou de ser a frase e passou a ser a estrutura — a seção O que vale nos dois viewports, que nasceu na rodada 4 e não deixou nada divergir depois dela.
Pendências aceitas: nenhuma.
Parte III — Detalhamento¶
Não preencher antes das revisões das seções 6 e 8 estarem aprovadas.
9. Regras de negócio¶
A restrição que decide o desenho¶
A API não sabe quais publicações existem, e isso é decisão da 0043 que continua valendo. As notas são arquivos .md dentro de apps/web/src/versoes/, lidos pelo bundle em tempo de build (RN4a da 0043) — é o que faz a tela abrir sem rede e o que garante que a pessoa lê as notas 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.
A consequência atravessa toda esta spec: quem conta é o navegador, quem guarda é o servidor. O front sabe a lista de publicações; a API sabe, de cada pessoa, quais ela já leu. O número no ícone é a subtração de um pelo outro, e ela acontece no cliente.
A alternativa foi considerada e recusada: passar o catálogo para o banco resolveria a contagem no servidor e quebraria a RN4a — a tela passaria a depender de rede para mostrar o que já está no código carregado, e alguém numa versão antiga leria as notas de uma versão que não está rodando. O preço de manter a decisão é uma contagem no cliente, e ela é barata.
O registro de leitura¶
RN1 — O que a pessoa leu é guardado em duas peças, e elas se somam. Um piso (a coluna last_seen_version que a 0043 já criou) e um conjunto de leituras avulsas (uma tabela nova). Uma publicação conta como lida quando é menor ou igual ao piso ou quando tem registro próprio.
Por que duas peças, e não só a tabela. A tabela sozinha exigiria, para cada pessoa que entra, gravar uma linha por publicação já existente só para dizer "isto tudo é anterior a você" — e o produto vai acumular publicações para sempre. O piso resolve isso com um valor: quem entra hoje nasce com o piso na versão do dia, e todas as anteriores já contam como lidas, sem uma linha sequer. É exatamente o que a RN9 da 0043 já faz, e não precisa mudar.
Por que duas peças, e não só o piso. O piso sozinho é o que existe hoje, e é o problema desta spec: ele não sabe dizer "li a 1.3.0 mas não a 1.2.0". A leitura fora de ordem é o caso normal quando alguém chega por link (Parte II, regra 1), e um piso não a representa.
RN2 — Abrir os detalhes de uma publicação grava a leitura dela, e só dela. Vale por qualquer caminho — índice, seletor do celular, link direto, ou o desfecho de um link quebrado (Parte II, 7.3). É a regra sem exceção, e ela existe assim para não haver dois tipos de "estar com os detalhes abertos" indistinguíveis na tela.
RN2a — Gravar a mesma leitura de novo não é erro. Reabrir uma publicação já lida é uso normal; a gravação é idempotente e responde bem.
RN3 — "Marcar todas como lidas" move o piso, e não grava N linhas. Ele passa a valer a maior publicação que o navegador conhece. Uma escrita, tempo constante, e o resultado é o mesmo: tudo abaixo do piso conta como lido.
RN3a — Ao mover o piso, as leituras avulsas abaixo dele são apagadas. Elas viraram redundantes — o piso já as cobre —, e mantê-las faria a tabela crescer para sempre com informação que não muda resposta nenhuma. É limpeza, não perda: a resposta a "esta publicação está lida?" continua a mesma.
A comparação é numérica, e essa frase evita um defeito que só aparece depois. Quem decide o que fica abaixo do piso é o comparar_versoes de core/version.py, carregando as linhas e comparando em Python — nunca um WHERE version <= :piso sobre a coluna, que é String(20) e compara como texto. Em texto, "1.9.0" > "1.10.0": movendo o piso para 1.10.0, a leitura da 1.9.0 não seria podada, e a da 1.2.0 seria — o oposto do pretendido, em silêncio, e só a partir da décima publicação de uma série. É o mesmo motivo pelo qual comparar_versoes existe.
RN4 — O piso só avança. Recebendo uma versão mais velha que a guardada, o servidor mantém a que está. É a regra que a 0043 já tem (RN8), e a razão continua: uma aba antiga noutro aparelho marcaria como lida uma versão anterior à que a pessoa já leu, fazendo a marca voltar.
RN5 — O que a API aceita é sempre X.Y.Z publicado. Sem o +N dos ambientes de desenvolvimento, sem "desconhecido", sem texto livre. Vale para as duas escritas — o piso e a leitura avulsa —, e quem retira o sufixo é a tela, antes de enviar. A API recusa com 422 para que esquecer disso apareça na hora, e não meses depois como um contador que não zera.
RN5a — A API não valida se a versão existe, e isso é deliberado. Ela não tem o catálogo (ver a restrição acima), então "1.2.0" e "9.9.9" são igualmente aceitáveis para ela. O prejuízo possível é uma pessoa registrar leitura de uma publicação inexistente, o que não afeta ninguém além do próprio contador dela — e a alternativa seria duplicar o catálogo no servidor para poder recusar, que é o que a RN4a da 0043 evita.
A contagem¶
RN6 — O número no ícone é quantas publicações conhecidas ainda não contam como lidas. Calculado no cliente, sobre a lista do bundle e o registro que veio no /auth/me. Acima de nove, exibe 9+ nas três superfícies (Parte II).
RN7 — Enquanto o registro de leitura não chegou, não há número. Nem zero, nem provisório: as três superfícies ficam sem distintivo até saber. Um contador que muda de 0 para 3 depois de dois segundos ensina a não confiar nele.
RN8 — Sem versão identificável no build, não há contagem nem gravação. É o estado da RN5a da 0043 (build sem o argumento de versão). A tela abre inteira e o histórico continua legível — ele não depende de saber em qual publicação este navegador está —, mas nenhuma publicação aparece como "a atual" e nada é marcado como lido: não se registra o que não se sabe nomear.
A tela¶
RN9 — O que abre por padrão é a publicação mais antiga ainda não lida. É por onde a história faz sentido, e é o que faz o contador cair de uma em uma na ordem em que as coisas aconteceram. Não havendo nenhuma por ler, abre a mais recente.
RN10 — O endereço nomeia a publicação aberta, e trocar de publicação substitui o endereço em vez de empilhar. Parte II, regra 1. O "voltar" do navegador sai da tela.
RN11 — Endereço de publicação inexistente não é tela de erro. A tela abre no estado normal (RN9) e acrescenta um aviso de que aquela publicação não foi encontrada.
RN12 — O aviso de "há versão mais nova no ar" migra para a tela (decisão do usuário, 2026-08-23), com as regras que a 0043 já definiu e que não mudam: compara-se (MAJOR, MINOR, PATCH, N); mais nova, e não apenas diferente, porque depois de uma volta atrás o servidor fica mais velho e oferecer "recarregar" mandaria a pessoa para trás; e um lado não identificável cala o aviso, porque afirmar sem saber é chute.
RN13 — O painel ancorado da 0043 é removido, nas duas formas. Duas maneiras de ler a mesma coisa seriam dois estados de leitura possíveis, e a divergência apareceria no pior momento — aquele em que uma marcou como lido e a outra não.
RN14 — A versão continua legível fora da tela, no menu do avatar e na linha da gaveta, como hoje. Aquilo responde a "qual versão você está usando?", que é pergunta de suporte e não de leitura, e não deve custar uma navegação.
10. Autorização e isolamento¶
A superfície de escrita são duas rotas, e nenhuma delas aceita alvo. As duas agem sobre current_user, sempre. É a mesma decisão da spec 0043, e ela é o que faz esta seção ser curta sem ser omissa: sem id, não há escalada a fechar.
10.1 Matriz de papéis¶
| Papel | Pode | Não pode |
|---|---|---|
ADMIN_GLOBAL | abrir a tela, ler as publicações, registrar a própria leitura, mover o próprio piso | registrar leitura ou mover piso de outra pessoa; ver quem leu o quê |
ADMIN_TENANT | idem | idem |
ANALYST | idem | idem |
OPERATOR | idem | idem |
VIEWER | idem | idem |
AGENCY_USER | idem — inclusive ler, e isso é decisão da 0043 (RN11): a publicação é conteúdo público | idem |
| não autenticado | nada, na prática — mas o texto das notas já está no navegador dela: ele vem no bundle que o nginx serve antes do login (RN11 da 0043). Não há tela que o mostre ali, e ele não é protegido | alcançar /novidades, que é rota da área autenticada; registrar leitura, porque não há de quem falar |
O negativo que vira teste é o mesmo nas seis linhas, e não é preguiça de escrever: o dado é do usuário, a rota não recebe id, e a única maneira de um papel alcançar o registro de outro seria existir um caminho que esta spec não cria. O teste que sustenta isso é a introspecção das rotas — nenhuma rota de leitura de versão tem {id} no caminho —, e ele já existe desde a 0043.
10.2 Permissões¶
Nenhuma permissão nova, e nenhuma exigida. As duas rotas são "apenas autenticado", e entram em _SEM_PORTEIRO_POR_DESENHO no meta-teste de cobertura de autorização, com o motivo escrito, ao lado do GET /auth/me e do PATCH /auth/me/last-seen-version que já estão lá pela mesma razão.
Criar um versions:read seria inventar porteiro para uma porta que a RN11 da 0043 decide manter aberta — e permissão declarada que nenhum endpoint exige é pior que ausente: responde "sim" a isto está protegido? sem proteger, que é o defeito que a spec 0035 catalogou.
O rbac_seed.py não é tocado. Consequência prática: o deploy desta spec não precisa do seed para funcionar, ao contrário do que a spec 0038 ensinou a temer — não há permissão nova que o banco possa não ter.
10.3 Isolamento por garantidora (tenant)¶
Não se aplica, e é preciso dizer por quê. O dado é do usuário, não do tenant: a tabela nova tem user_id e nada mais que aponte para negócio. Não há FK vinda do corpo a resolver no escopo do solicitante — o corpo tem uma string de versão e mais nada —, e não há consulta a registro de terceiro, então não existe o caso de "registro de outro tenant" a devolver 404.
O ADMIN_GLOBAL não é caso especial aqui. Ele registra a própria leitura como qualquer um. Não há listagem em que a ausência de filtro de tenant dele vazasse algo, porque não há listagem: a única leitura é a do próprio registro, dentro do /auth/me.
A tabela nova não recebe tenant_id, e isso é decisão. Ela não é entidade de negócio; é estado de interface de uma pessoa. Pôr tenant_id ali sugeriria um recorte que não existe — a mesma pessoa não tem leituras diferentes por garantidora — e criaria uma coluna que ninguém filtraria, que é como um filtro esquecido nasce parecendo intencional.
10.4 Isolamento por imobiliária¶
Não se aplica, pelo mesmo motivo, e com uma consequência que precisa estar escrita. O AGENCY_USER tem o registro dele como todo mundo, sem partner_agency_id envolvido em nada.
A consequência é que a publicação é lida também pela imobiliária parceira, e ela é herdada da 0043 (RN11), não criada aqui. Quem escreve a nota precisa saber disso: nada de nome de cliente, valor, dado de garantia ou nome de pessoa. A revisão humana no /ship é a única barreira, e esta spec não acrescenta outra — dizer o contrário seria fingir controle onde não há.
10.5 Plataforma × cliente (SaaS admin)¶
O painel do SaaS admin herda o ícone e a rota, e isso é o desfecho certo. Ele usa a mesma casca desde a spec 0033; como não há porteiro, o ícone aparece lá e a tela abre. Quem administra a plataforma é quem mais precisa saber que versão está no ar.
Nada de dado de cliente atravessa por aqui. A publicação é a mesma para todo mundo porque o produto é o mesmo; ela não cita garantidora, não cita tenant, e não é dado de negócio. Um ADMIN_GLOBAL lendo a tela não alcança nada do cliente por este caminho.
Nenhuma tela da garantidora passa a aceitar tenant_id de fora — não há parâmetro de escopo em rota nenhuma desta spec.
10.6 O que esta spec deliberadamente não permite¶
Ninguém consegue saber quem leu o quê. O registro sai apenas dentro do /auth/me de quem chama; não há listagem, não há agregado, não há filtro por usuário. Está no fora de escopo da Parte I e é repetido aqui porque a tabela torna a pergunta tecnicamente respondível pela primeira vez — e o momento de dizer que ela não será respondida é antes de existir a rota, não depois de alguém pedir.
11. Impacto por app¶
apps/api¶
models/user_version_read.py(novo) —user_id(FK parausers,ON DELETE CASCADE),version(String(20)), e ocreated_atdo mixin, que é quando a pessoa leu. Índice único em(user_id, version): é o que faz a gravação repetida ser idempotente no banco, e não só no serviço. Semtenant_id, pelo motivo da seção 10.3.models/user.py— olast_seen_versionpermanece e muda de sentido: de "a última que você viu" para piso ("tudo até aqui conta como lido"). A docstring precisa dizer isso, porque o nome não mudou e a leitura errada é plausível.schemas/user.py— oMeResponseganharead_versions: list[str], que traz só as leituras acima do piso; as de baixo são redundantes (RN3a) e as pruna o próprio serviço. Continua fora doUserResponse: é estado de leitura de uma pessoa, e a lista da Equipe não tem por que contar a todos quem leu a nota de versão.api/v1/routers/auth.py—POST /auth/me/version-reads(nova), e o_montar_medaquele arquivo precisa do campo novo. É o mesmo aprendizado da spec 0041 que a 0043 registrou: oMeResponseé montado à mão, e campo que não seja acrescentado ali não sai na resposta, em silêncio.repositories/user_version_read_repo.py(novo) — gravação idempotente, leitura das versões de um usuário, e a poda das que ficaram abaixo do piso (com a comparação em Python, pela RN3a).schemas/user.py— o corpo da rota nova reaproveita oLastSeenVersionRequest, que já existe com opatterndeX.Y.Ze a explicação de por que o+Né recusado. As duas rotas recebem exatamente a mesma coisa: uma versão publicada e nada mais. Um schema novo com o mesmo campo e a mesma validação seria um segundo lugar para opatterndivergir.services/user_service.py(ou serviço próprio) — a regra do piso que só avança (RN4) já existe no router da 0043 e passa a conviver com a poda da RN3a.core/version.py— sem mudança.versao_publicadaecomparar_versoesjá são o que as duas rotas precisam.tests/test_authorization_coverage.py— a rota nova entra em_SEM_PORTEIRO_POR_DESENHOcom o motivo. Sem isso o meta-teste do porteiro reprova, e com razão: a dispensa tem de ser declarada, não presumida.- Migration — cria
user_version_reads. Não faz backfill e não mexe emlast_seen_version: o piso de cada pessoa já está lá com o valor certo, e é ele que preserva o estado de quem leu alguma coisa sob a 0043. Odowngradederruba a tabela, e é simétrico de verdade — o dado é derivado, e quem o perder vê a marca uma vez a mais. - Testes — o piso somado às avulsas; a idempotência; o piso que não retrocede; a poda ao mover o piso; os 422 de formato (inclusive o
+N); o 401 sem sessão; e a inexistência de rota com alvo.
apps/web¶
App.tsx— rotas/novidadese/novidades/:versao, dentro da área autenticada.components/custom-sider/index.tsx— sem mudança, e é decisão. A regra doCLAUDE.mddo web ("módulo novo exigeApp.tsxe a sider") existe para o item não sumir do menu; aqui o ponto de entrada é o ícone da barra, que já existe, e um item na navegação lateral seria o segundo caminho para a mesma tela. Vale registrar para ninguém "corrigir" a ausência achando que foi esquecimento.components/versao/— reescrita. OIconeNovidadesdeixa de abrir popover e passa a navegar, com distintivo numérico;PainelNovidades,LinhaNovidadeseDialogNovidadesperdem a moldura de painel e viram os pedaços da página. OMarcaNaoLidocontinua, e oPontoNaoLidotambém — a aba "Mais" segue com ponto (Parte II).pages/novidades/(novo) — a página, nas duas formas: duas colunas com índice à direita no desktop, seletor recolhido no topo em 375px.versoes/index.ts— ganha a contagem de não lidas a partir do piso mais o conjunto, e a busca de uma publicação por número (para o/:versao).shared/identity/store.tseproviders/auth.ts—read_versions: string[]entra noIdentitye no tipo local do/auth/me. Sem a declaração não compila, e otsclimpo é o portão do web.data-testid— o ícone com o número, cada linha do índice, o seletor do mobile, o botão de marcar todas como lidas, a faixa de versão nova e o botão de recarregar, o aviso de publicação não encontrada, e o bloco de detalhes de cada publicação.- PWA — rota nova, sem asset novo. Ela precisa se comportar como as demais na tela instalada: abrir, voltar, e ser alcançável offline no que vem do código carregado.
apps/e2e¶
- Spec nova cobrindo: o número no ícone e sua queda de um em um; a persistência entre sessões; a leitura fora de ordem por link direto; o "marcar todas como lidas"; os dois viewports sem rolagem horizontal; o
AGENCY_USERalcançando a tela; e o estado de versão não identificada. - O spec
versao-e-novidades.spec.tsda 0043 é reescrito, não acrescentado: ele testa o popover, que deixa de existir. Os testes que sobrevivem — a comparação com o servidor, a ausência do ícone na barra do mobile, oAGENCY_USER— mudam de superfície, não de pergunta. - Sem seed novo: o mundo é montado pela API, como nas specs 0038 a 0043.
12. Contrato de API¶
O que muda no que já existe¶
O MeResponse ganha um campo:
{
"id": "uuid", "name": "...", "email": "...",
"last_seen_version": "1.2.0",
"read_versions": ["1.4.0", "1.6.0"]
}
last_seen_version— o piso. Tudo menor ou igual conta como lido.nullé "nunca leu nada", e continua sendo o que faz a nota de estreia alcançar quem já usava o produto (RN10 da 0043). O nome não muda para não quebrar o cliente por uma renomeação; o sentido está na docstring do modelo e aqui.read_versions— as leituras avulsas acima do piso, sem ordem garantida. As de baixo não vêm porque são redundantes e foram podadas (RN3a). Lista vazia é o caso normal de quem está em dia.
A rota não muda de forma, e passa a ser o "marcar todas como lidas". Corpo { "version": "X.Y.Z" }, o mesmo pattern de hoje, o mesmo comportamento de só avançar (RN4). O que muda é o efeito colateral: mover o piso apaga as leituras avulsas que ficaram abaixo dele (RN3a).
| Erro | Quando |
|---|---|
401 | não autenticado |
422 | version ausente ou fora de X.Y.Z — o +N dos ambientes de desenvolvimento não é enviado (RN5) |
O que é novo¶
Corpo: { "version": "1.4.0" }. Registra que quem chamou leu aquela publicação. Age sobre current_user, sempre — não há id no caminho nem no corpo, e é isso que faz esta spec não ter escalada a fechar.
Idempotente (RN2a): chamar duas vezes para a mesma versão responde 200 das duas, e o estado é o mesmo. Reabrir uma publicação já lida é uso normal, não erro.
Não valida se a versão existe (RN5a): a API não tem o catálogo, que vive no bundle. Ela valida o formato, e nada além.
Devolve o MeResponse inteiro, e não só o campo — é o que o PATCH já faz, e é o que permite à tela reconciliar o estado numa resposta só, em vez de emendar localmente o que ela acha que gravou.
| Erro | Quando |
|---|---|
401 | não autenticado |
422 | version ausente ou fora de X.Y.Z, inclusive com o +N |
Não há DELETE, e é decisão: "marcar como não lido" está no fora de escopo da Parte I.
Migration¶
xxxx_leitura_por_publicacao — cria user_version_reads:
| Coluna | Tipo | Nota |
|---|---|---|
id | UUID | UUIDMixin |
user_id | UUID, FK users.id ON DELETE CASCADE | a leitura morre com a pessoa |
version | String(20) | X.Y.Z publicado |
created_at / updated_at | timestamptz | TimestampMixin; o created_at é quando leu |
Índice único em (user_id, version) — é ele que faz a idempotência ser garantia do banco, e não só do serviço.
Não faz backfill e não toca users.last_seen_version. O piso de cada pessoa já está lá com o valor certo: quem leu alguma coisa sob a 0043 tem o piso naquela versão, e continua com tudo até ali contando como lido. Preencher a tabela com o histórico seria escrever N linhas por pessoa para dizer o que o piso já diz.
downgrade derruba a tabela. Simétrico de verdade: o dado é derivado, e o custo de perdê-lo é ver a marca uma vez a mais.
13. Critérios de aceite¶
A contagem
- [x] CA1. Com três publicações acima do piso e nenhuma lida, o ícone mostra 3 — nas três superfícies (ícone do desktop, linha da gaveta, ponto da aba "Mais").
- [x] CA2. Abrir os detalhes de uma delas faz o número virar 2, e ele continua 2 depois de recarregar a página e depois de entrar por outro navegador.
- [ ] CA3. Com mais de nove por ler, as três superfícies mostram
9+. - [x] CA4. Enquanto o registro de leitura não chegou, nenhuma das três superfícies mostra distintivo — nem zero (RN7).
- [ ] CA5. Usuário criado agora nasce com o piso na versão corrente e contador zero, mesmo havendo publicações anteriores (RN1, e é a RN9 da 0043 continuando a valer).
A leitura
- [ ] CA6. Abrir uma publicação marca só ela: as anteriores não lidas continuam não lidas, e o índice as mostra assim (RN2).
- [ ] CA7. Chegar pelo endereço direto de uma publicação marca aquela, e o contador cai exatamente um — a leitura fora de ordem é estado válido (RN2).
- [ ] CA8. Abrir a mesma publicação duas vezes responde
200das duas e não muda o contador na segunda (RN2a). - [x] CA9. Endereço de publicação inexistente abre a tela no estado normal — a mais antiga não lida —, avisa que não encontrou, e marca aquela que abriu (RN11 e RN2).
Marcar todas
- [ ] CA10. "Marcar todas como lidas" zera o contador numa ação, e o estado sobrevive a recarregar (RN3).
- [ ] CA11. Depois dele, as leituras avulsas abaixo do novo piso não estão mais no
read_versionsdo/auth/me(RN3a). - [x] CA12. Sem nada por ler, o botão aparece desabilitado em vez de sumir, e o texto distingue quem leu tudo de quem acabou de chegar.
A tela
- [ ] CA13. Entrar pelo ícone abre a publicação mais antiga não lida; não havendo nenhuma, a mais recente (RN9).
- [x] CA14. Trocar de publicação no índice substitui o endereço, e o "voltar" do navegador sai da tela em vez de caminhar pelas publicações (RN10).
- [x] CA15. Em 375px o índice é o seletor recolhido no topo, a lista expandida rola por dentro sem tomar a tela, e nada rola na horizontal.
- [x] CA16. Com uma publicação só, o seletor do mobile perde a seta e deixa de ser tocável; no desktop o índice continua sendo uma lista de uma linha.
- [x] CA17. O popover da 0043 não existe mais — clicar no ícone navega, e nenhum painel abre por cima da tela (RN13).
- [x] CA18. A versão continua legível no menu do avatar e na linha da gaveta, sem entrar na tela (RN14).
O aviso de versão nova
- [ ] CA19. Com o servidor numa versão mais nova, a faixa aparece na tela com o botão de recarregar — acima do conteúdo no desktop e acima do seletor em 375px (RN12).
- [x] CA20. Com o servidor numa versão mais velha (a volta atrás), a faixa não aparece.
- [x] CA21. Com um dos dois lados não identificável, a faixa não aparece.
- [x] CA22. Falhando a comparação, o erro fica confinado à faixa, com "tentar novamente", e o resto da tela continua inteiro.
Versão não identificada
- [ ] CA23. Sem versão no build, a tela abre inteira, nenhuma publicação aparece como "a atual", nada é marcado como lido e não há contagem (RN8).
Autorização
- [x] CA24.
POST /auth/me/version-readssem autenticação devolve 401. - [x] CA25.
PATCH /auth/me/last-seen-versionsem autenticação devolve 401. - [x] CA26. As duas rotas gravam em quem chamou, e não existe rota com id para registrar leitura de outra pessoa — verificado por introspecção das rotas.
- [x] CA27.
versionfora deX.Y.Zdevolve 422 nas duas rotas, inclusive com o+Ndo dev-remote, e nada é gravado. - [x] CA28. O
AGENCY_USERabre a tela, lê e registra a própria leitura (RN11 da 0043). - [x] CA29. O
read_versionssai só noMeResponsede quem chama, e não noUserResponseda lista da Equipe.
O que ficou por marcar, e por quê. Dez critérios seguem abertos, e nenhum é dívida de implementação — todos falam de mais de uma publicação, e o produto tem uma só (a 0.1.0, de ontem). São CA3 (o 9+), CA5 (usuário criado agora com contador zero havendo anteriores), CA6 e CA7 (marcar só aquela, e a leitura fora de ordem por link), CA8 (a segunda chamada não muda o contador), CA10 e CA11 (o "marcar todas" e a poda), CA13 (abrir na mais antiga não lida), CA19 (a faixa de versão mais nova aparecendo), e CA23 (a tela sem versão identificável, que deixou de ser o estado do dev-remote quando a tag v0.1.0 nasceu).
O comportamento de cada um está coberto por teste na API — piso mais avulsas, idempotência, poda numérica, o piso que não retrocede —, e o que falta é a ponta visível com um histórico que ainda não existe. O spec e2e já tem os ramos escritos: eles passam a exercitar o caso positivo sozinhos assim que houver uma segunda publicação, sem mudança nenhuma.
14. Plano de implementação¶
Na ordem obrigatória do monorepo — o contrato desce da API.
- [x]
apps/api— model, migration, repository, schema, rota nova, e o piso ganhando a poda - [x]
apps/api— Swagger nas duas rotas, docstrings do modelo (o sentido novo delast_seen_versioné o que mais importa registrar) e a entrada no meta-teste do porteiro - [x]
apps/api— testes: piso + avulsas, idempotência, poda, 401, 422, ausência de rota com alvo - [x]
apps/web—Identity/UserResponse, a contagem emversoes/index.ts, a página nos dois viewports, o ícone que navega, e a remoção do popover - [x]
apps/e2e— a spec nova, e a reescrita doversao-e-novidades.spec.tsda 0043 - [x]
make preflight,make testemake lintsem apontamento novo - [ ] Validado no browser em desktop e 375px, com o PWA reaberto para furar o cache
A ordem entre a API e o web importa mais que de costume aqui: a contagem acontece no cliente, mas ela lê read_versions, que só existe depois que a API o devolve.
15. Decisões em aberto¶
Nenhuma. As três decisões de produto foram tomadas na entrevista — a dor que motiva a troca de superfície, a linha de base de quem entra (zero, não o histórico inteiro) e o que conta como ler (abrir os detalhes) —, e a que o portão da experiência levantou foi decidida: o aviso de "há versão mais nova" vai para a tela, em vez de sair de cena.
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 | 0 | 3 | Aprovada |
Rodada 1 — aprovada, e o que sustentou foi a segurança ser curta por construção. O revisor percorreu os cinco critérios e todos fecharam pelo mesmo motivo: as duas rotas não recebem id, então não há escalada a fechar; a tabela nova não tem FK de negócio nem tenant_id, então não há isolamento a aplicar; e nenhuma permissão é criada, então o rbac_seed.py não é tocado — o que faz o deploy desta spec não depender do seed, ao contrário do que a 0038 ensinou a temer.
Os três Minor foram corrigidos, e o primeiro valia por si: a poda da RN3a não dizia que a comparação é numérica. Um WHERE version <= :piso sobre a coluna String(20) compara como texto, onde "1.9.0" > "1.10.0" — movendo o piso para 1.10.0, a leitura da 1.9.0 não seria podada e a da 1.2.0 seria. Defeito silencioso que só apareceria na décima publicação de uma série, e é exatamente o motivo pelo qual o comparar_versoes existe.
Os outros dois: o corpo da rota nova reaproveita o LastSeenVersionRequest em vez de ganhar schema próprio — as duas rotas recebem a mesma coisa, e um segundo schema seria um segundo lugar para o pattern divergir; e a linha "não autenticado" da matriz dizia que a pessoa "lê as notas na tela de login", o que soava como funcionalidade visível. O revisor conferiu: não há tela nenhuma ali. O que é verdade é que o texto já está no navegador dela, porque vem no bundle — fundamento técnico da RN11, não uma tela.
Resultado: aprovada na rodada 1.
Riscos aceitos: nenhum.
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 | 0 | 0 | 3 | Aprovada |
| 2 | apps/web | 1 | 3 | 2 | Reprovada |
| 3 | apps/web | 0 | 4 | 2 | Reprovada |
| 4 | apps/web | 0 | 2 | 3 | Reprovada |
| 5 | apps/web | 0 | 0 | 5 | Aprovada |
| 6 | branch | 0 | 1 | 2 | Aprovada |
A fase de API passou de primeira, com três Minor corrigidos. O que mais valia era uma corrida estreita e real: a gravação idempotente fazia SELECT e depois INSERT, e dois cliques quase simultâneos passariam os dois pelo SELECT antes de qualquer um gravar — a segunda requisição estouraria no índice único e viraria 500 numa rota cuja documentação promete 200. Ganhou um begin_nested, que desfaz só o INSERT em vez da transação inteira.
A fase de web levou quatro rodadas, e a maioria dos achados foi de estado, não de estrutura. A rodada 2 pegou o Critical: o efeito que redireciona um link quebrado apagava o próprio aviso de "não encontrada" no ciclo seguinte ao que deveria mostrá-lo. Junto vieram o botão virando ícone sem rótulo em 375px (a spec proíbe por escrito), as duas falhas de gravação engolidas sem mensagem, e o ícone tirando o ADMIN_GLOBAL da casca do painel administrativo — resolvido com uma rota irmã /admin/novidades e um desvio que escolhe pela área.
A rodada 3 achou que duas dessas correções tinham defeito remanescente, e uma delas era inatingível por um motivo que só se vê olhando a API: o texto "nada novo desde que você entrou" dependia de last_seen_version nulo, e a API grava o piso na criação (RN9 da 0043). Quem entra hoje nasce com ele preenchido, igual a quem leu tudo. O que separa os dois é a data da conta, e foi ela que entrou no lugar.
A rodada 4 pegou um defeito visível no estado em que o produto está agora: com uma publicação só, o seletor do mobile usava MobileDetailSection com colapsavel={false} — que mantém o corpo sempre aberto, é a semântica dele, e nenhum outro uso do app passa false. O resultado seria a versão repetida em duas linhas adjacentes, uma delas clicável sem levar a lugar nenhum.
Rodada 6 — a revisão de branch aprovou, e o Important dela é o tipo que só aparece pensando no futuro. O teste do CA16 detectava "há mais de uma publicação" contando linhas do índice — que vivem dentro de um Collapse que nasce fechado e desmonta os filhos. A contagem daria zero para sempre, o teste entraria no ramo errado, e quebraria na primeira publicação nova, parecendo regressão de produto sem ser. Agora ele pergunta pelo próprio seletor. Os dois Minor: uma publicação sem data: (o contrato permite) faria o texto de "acabou de chegar" cair para o lado errado, e a asserção do CA12 aceitava qualquer um dos dois textos em vez de fixar qual.
Resultado: aprovada na rodada 6 da revisão de branch.
Defeitos aceitos: nenhum.
18. Registro¶
O dev-remote deixou de ser "versão não identificada" no meio desta implementação, e isso mudou o que a suíte consegue provar. A tag v0.1.0 nasceu no deploy da spec 0043, horas antes; a partir dali o ambiente passou a reportar 0.1.0 +1, e o caminho de versão identificada — que a 0043 só conseguia cobrir por ramo condicional — virou o caminho normal. Os testes continuam com os dois ramos: o segundo passou a ser o do build sem o argumento.
A rota nova reaproveita o LastSeenVersionRequest em vez de ganhar schema próprio, como a spec pediu, e a docstring dele passou a nomear as duas rotas. Um segundo schema com o mesmo campo e a mesma validação seria um segundo lugar para o pattern divergir — e o dia em que divergisse, uma das rotas aceitaria o que a outra recusa sem que nada acusasse.
O created_at entrou na identidade do front, e ele serve a uma pergunta só. Distinguir quem leu tudo de quem acabou de chegar parecia ser uma leitura do piso, e não é: a API o grava na criação (RN9 da 0043), então quem entra hoje nasce com ele preenchido, igual a quem leu tudo. O que separa é a data da conta contra a data das publicações.
A poda tem teste de reversão, e ele é o mais valioso da fase de API. Trocando o comparar_versoes por um <= de texto na coluna String(20), o teste test_a_poda_compara_numericamente_e_nao_como_texto fica vermelho — porque em texto "1.9.0" > "1.10.0", e a poda apagaria a leitura errada a partir da décima publicação de uma série. É um defeito que só apareceria meses depois, em silêncio.
Um comentário meu afirmava uma proteção que não existe, e foi corrigido pela verificação e não pela leitura. Eu tinha escrito que manter a poda dentro do if do piso impedia uma aba atrasada de apagar leituras. Revertendo — tirando a poda do if — nenhum teste ficou vermelho, porque a variação não muda comportamento: uma versão menor que o piso guardado só alcança o que a poda anterior já levou. O comentário passou a dizer o que é verdade (fica ali por economia), e o teste vizinho passou a dizer que foi verificado assim.