0041 — A Equipe passa a administrar de verdade: no celular, sem 403, e sem mudar acesso em silêncio¶
| Status | CONCLUÍDA |
| Branch | spec/0041-equipe-e-perfis-no-mobile |
| Criada em | 2026-08-20 |
| Apps afetados | api · web · e2e |
| Revisão do problema | aprovada na rodada 7 |
| Revisão da experiência | aprovada na rodada 4 |
| Revisão do detalhamento | aprovada na rodada 18 |
| Revisão da implementação | api na 2ª · web na 6ª · e2e na 3ª · branch na 3ª |
Status:
RASCUNHO→APROVADA→EM IMPLEMENTAÇÃO→CONCLUÍDA
A spec tem três partes, escritas em ordem, e quatro portões — cada um revisado por um subagente de contexto limpo, em até 3 rodadas. A máquina de revisão é do plugin superpowers; a régua é a rubrica correspondente em .claude/rubrics/. Achados vêm classificados: qualquer Critical reprova, Important em aberto reprova salvo decisão registrada do usuário, e Minor vira pendência aceita.
- A Parte I diz qual é o problema e como se convive com ele hoje. Revisada na seção 6 (necessidade × jornada de hoje). É o portão mais barato de todos, e o único que pergunta contra que problema isto está sendo construído.
- A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
- A Parte III diz como se constrói. Revisada na seção 16 (segurança × clareza técnica) — autorização, isolamento, e se sobrou decisão para quem vai codar.
- A implementação é medida contra as três, na seção 17 (aderência × qualidade × testes) — uma revisão por fase, e uma da branch inteira antes de fechar.
Cada parte só se escreve depois que a anterior passou. Descrever tela sobre problema mal enquadrado, ou detalhar endpoint sobre experiência vaga, é o jeito mais caro de descobrir que era outra coisa.
Parte I — O problema¶
Aqui não entra solução: nem tela, nem endpoint, nem botão. Só o que dói, para quem, e o que se faz hoje. Se você já sabe qual é a tela, guarde para a Parte II.
1. Dor¶
A Equipe é a tela que administra quem entra no sistema da garantidora, e ela não administra nada. Uma parte do que ela oferece está fora do alcance no celular; outra parte não funciona em aparelho nenhum; e o que funciona não deixa registro. As três coisas se somam no mesmo lugar, e é por isso que estão na mesma spec.
Não dá para tirar o acesso de alguém pelo celular. Em 375px sobrevivem duas informações — nome e e-mail. Perfil, situação, data de cadastro e os botões de Editar e Ativar/Desativar ficam fora da tela, e a página não rola na horizontal: não há como alcançá-los. Alguém sai da empresa num sábado, ou perde o celular com a sessão aberta; quem tem users:update está na rua, com o telefone na mão, e a única coisa que consegue fazer é ver o nome da pessoa na lista. A janela entre a decisão e a execução é tempo em que uma conta que devia estar fechada continua entrando.
E quando o acesso finalmente cai, o sistema não guarda nada disso. Desativar é a única forma de tirar acesso que a tela oferece — excluir usuário não existe em tela nenhuma, embora a rota exista na API. As duas se distinguem por uma coisa só, e é a que importa para quem administra: desativar mantém a pessoa na lista, marcada; excluir a tira da lista. Nenhuma das duas apaga o registro nem libera o e-mail — o DELETE grava deleted_at, e o índice de e-mail continua único. E nem PATCH /users/{id} nem DELETE /users/{id} gravam auditoria — nem quem executou, nem quando. O contraste é o que chama atenção: conceder e revogar perfil pela tela de edição já gravam (ASSIGN_USER, REVOKE_USER), assim como análise, decisão, garantia, documento e cobrança. Tirar o acesso inteiro de alguém é a mudança mais grave das três, e é a que acontece em silêncio. Depois do fato, "quem desativou fulano, e quando?" é pergunta sem resposta.
E a auditoria de perfil tem um furo próprio, pior do que não existir: ela cobre a tela de edição e não cobre o perfil escolhido no formulário de cadastro, que entra por vínculo direto. Uma auditoria que cobre metade dos caminhos responde "ninguém concedeu" quando a resposta certa é "foi concedido por onde eu não olho" — e é assim que um usuário criado já como administrador não aparece em lugar nenhum.
Não dá para saber quem tem qual perfil, nem varrer quem ainda tem acesso. "Fulano ainda tem acesso?" e "com que perfil?" são as duas perguntas que a tela existe para responder.
- Qual perfil? A coluna aparece como uma lasca de 14px em 375px, e não há outro caminho. No computador ela se lê normalmente — esta metade é de viewport.
- Está ativo? No computador, a coluna Status diz "Ativo" ou "Inativo" por extenso, e responde por pessoa. Em 375px ela está a 0px, e o que resta é a linha ficar 40% mais clara: sem rótulo, sem legenda, invisível no sol.
Mas varrer não funciona em viewport nenhum. A tela oferece um filtro Todos/Ativo/Inativo, que fica acima da tabela e sobrevive ao corte — e ele não filtra: a tela manda is_active na consulta, e a rota não declara o parâmetro. Medido: as três opções devolvem a mesma lista, com o mesmo inativo dentro. Perguntar "quem está inativo?" — que é como se confere uma equipe inteira, e não uma pessoa — não tem resposta em computador nenhum.
Não dá para cadastrar ninguém — em viewport nenhum, e não por causa da tela. O botão + e o formulário funcionam no celular; o que não funciona é salvar. POST /users recusa perfil de sistema para quem não é administrador da plataforma, e todos os perfis do seed são de sistema. O campo Perfil é opcional na tela, mas o padrão do contrato é OPERATOR — que também é de sistema —, então deixar em branco dá o mesmo 403. Medido com sessão de gestor: com perfil escolhido, 403; sem escolher, 403. A mensagem que a pessoa lê fala em "perfis do sistema não podem ser alterados", sobre uma tela onde ela não estava alterando perfil nenhum.
Há um contorno, e é o que a própria mensagem de erro sugere sem dizer: criar um perfil personalizado e escolhê-lo no cadastro — perfil que não é de sistema passa, e devolve 201. Quem descobre isso paga o preço de montar um conjunto de permissões à mão, permissão por permissão, só para conseguir pôr uma pessoa para dentro; e fica com um perfil a mais no tenant, gêmeo de um que já existia. O efeito prático: o caminho normal de cadastrar alguém não funciona, e o que funciona é uma volta que ninguém tem por que adivinhar.
Corrigir o que ficou errado depende de um botão fora da tela. Trocar o nome de quem foi cadastrado com apelido, ou tirar de alguém um perfil concedido a mais, passa pelo Editar, que está na coluna cortada. O formulário de edição em si funciona bem em 375px — o que falta é chegar até ele.
O e-mail não entra nessa conta, e não é por causa do celular. Ele não é editável em lugar nenhum —
UserUpdateaceita sónameeis_active, e o campo vem desabilitado — e também não há como apagar o usuário e recomeçar: nenhuma tela do app exclui usuário. Um endereço digitado errado, hoje, não tem conserto dentro do produto. Fica fora desta spec (decisão do usuário, 2026-08-20): trocar o endereço é trocar o login, e isso arrasta unicidade, sessão aberta e aviso à pessoa.
E a lista não é da equipe. O convite de imobiliária cria o usuário da parceira com o tenant_id da garantidora e um partner_agency_id, nascendo inativo até ele aceitar; list_by_tenant não separa os dois públicos e a tela não os distingue. Medido no ambiente de desenvolvimento: 10 dos 17 usuários que a Equipe lista são de imobiliária. A tela que se apresenta como "Gerencie usuários internos" mostra, em maioria, gente do canal.
Isso não é só rótulo errado — ele estraga as duas perguntas que a tela responde:
- "Esta pessoa ainda tem acesso?" Uma linha "Inativo" pode ser acesso revogado ou convite de parceira ainda não aceito, e a tela não diz qual. É a mesma marca visual para dois fatos opostos.
- "Quem eu posso desativar?" Tirar o acesso de um usuário de imobiliária não é decisão da Equipe: quem governa esse acesso é a parceria, e a tela de Imobiliárias é onde ela vive.
E é o mesmo ruído que faz "achar uma pessoa" custar mais do que deveria, porque a lista tem o dobro do tamanho que a pergunta pede.
O que se perde ao separar os dois públicos, e é decisão consciente. Hoje a Equipe é o único lugar do produto onde se desativa o acesso de um usuário de imobiliária: o
PATCHfunciona para eles porque otenant_idé o mesmo, e a tela de Imobiliárias só oferece "Enviar convite" — não lista nem desativa ninguém. Tirando-os daqui, esse caminho some e nada o substitui nesta spec: o caso de sábado, aplicado a alguém da parceira, passa a não ter resposta. Foi aceito assim (decisão do usuário, 2026-08-20) porque administrar o acesso do canal é da parceria, e construir isso na tela de Imobiliárias é outra spec — mas fica escrito que a capacidade existia e foi abandonada de propósito, e não por descuido.
Achar uma pessoa é rolar a lista inteira. A Equipe não tem busca, ao contrário das cinco listas de negócio do produto — Inquilinos, Imóveis, Imobiliárias, Análises e Garantias. Já é desconfortável com as 17 pessoas do ambiente de desenvolvimento, e continuará sendo com as 7 que sobram depois de separar os dois públicos: é o custo que aparece antes de qualquer uma das tarefas acima, porque todas começam por encontrar alguém — e é o único ponto em que a Equipe destoa de todas as outras listas do produto; é o custo que aparece antes de qualquer uma das tarefas acima, porque todas começam por encontrar alguém.
Em Perfis de Acesso, a dor é outra e menor — e uma metade dela nem é de viewport.
- Consultar não funciona. "O que este perfil dá?" é a pergunta que a tela responde no computador e não responde no celular: a Descrição é espremida em 118px e uma frase vira seis linhas, de modo que cinco perfis de sistema enchem duas telas. É a pergunta que aparece antes de conceder um perfil a alguém pela Equipe — e é por isso que a lista de Perfis entra nesta spec junto com a Equipe, e não sozinha.
- Marcar a permissão errada é o resultado natural do formulário. No quadro de permissões o checkbox vai para cima do rótulo em vez de ficar ao lado, então o quadrado que se toca fica visualmente ligado ao texto errado. Aqui o custo do engano não é ergonômico: marcar a caixa vizinha é conceder um acesso que não se quis conceder.
Montar um perfil, porém, é tarefa de mesa (decisão do usuário, 2026-08-20): ninguém percorre sessenta permissões no celular, e o formulário de perfil não vira mobile nesta spec — o que se conserta nele é o checkbox, porque conceder acesso errado não é um problema de tamanho de tela.
A administração é o último bloco do produto que ficou de mesa — e, no caminho, descobriu-se que parte dela não estava de pé em mesa nenhuma.
2. Quem sente¶
Três papéis abrem a Equipe, e a dor deles não é a mesma.
| Papel | O que tem | Como a tela o atinge |
|---|---|---|
ADMIN_TENANT (gestor, sócio) | users:list, users:read, users:create, users:update, users:delete, roles:list, roles:read — e escrita em perfil por papel de sistema, não por permissão | Sente tudo. É o único que perde ação: desativar, excluir, editar usuário, editar perfil. E é ele quem toma o 403 do cadastro — o defeito é definido por não ser ADMIN_GLOBAL |
ANALYST | users:list, users:read | Só leitura — e é a leitura que falha: abre a Equipe, não descobre o perfil de ninguém, e o filtro de situação não filtra para ele também |
VIEWER | users:list, users:read | Idem |
E há um quarto momento, que não é de tela: quem abre a Auditoria depois do fato para perguntar "quem tirou o acesso de fulano, e quando?" — hoje, e independentemente de aparelho, não encontra nada. É o ADMIN_TENANT de novo, e é a única das dores que ele sente depois de o dano estar feito, e não durante.
Dentro da garantidora, Perfis de Acesso é só do ADMIN_TENANT: nenhum outro papel de tenant tem roles:list, e as rotas de escrita de perfil são gateadas por papel de sistema (require_system_role), não por resource:action. O ADMIN_GLOBAL também tem roles:list — ele recebe todas as permissões por compreensão no seed —, mas ele é da plataforma e suas telas estão fora de escopo (seção 5).
Quem não abre nenhuma das duas: OPERATOR e AGENCY_USER não têm users:list, e o item não aparece no menu deles.
É caminho obrigatório, não exceção. Não há outro lugar no produto para criar, desativar ou excluir um usuário, nem para montar um perfil personalizado. Quem precisar de qualquer uma dessas coisas passa por aqui, no aparelho que tiver na mão.
Não há volume a medir. A produção subiu na spec 0028 e ainda não tem cliente nenhum — o único usuário lá é o admin do seed. O alcance, então, se sustenta pelo desenho e não por número: todo ADMIN_TENANT de toda garantidora, sempre; mais ANALYST e VIEWER sempre que abrirem a lista.
E é o alcance que explica por que o 403 do cadastro nunca apareceu: o único ambiente com uso real é o de desenvolvimento, onde os usuários são criados pelo seed ou pelo ADMIN_GLOBAL. Nenhum teste da API cobre a criação por um ADMIN_TENANT — todas as criações da suíte são feitas com o token de admin global.
3. A jornada de hoje¶
O que a tela mostra, medido¶
Aberta em 375px (o aparelho de referência do app), com sessão de gestor, contra o ambiente de desenvolvimento. As coordenadas são as reais, lidas do getBoundingClientRect de cada célula:
Equipe (/users). O painel vai de 16 a 359; a tabela mede 844px e o painel tem overflow: hidden.
| Coluna | Onde está | Quanto se vê |
|---|---|---|
| Usuário (avatar, nome, e-mail) | 17 → 345 | 328px — inteira |
| Perfis | 345 → 531 | 14px — uma lasca, nada legível |
| Status | 531 → 611 | 0 |
| Cadastrado em | 611 → 727 | 0 |
| Ações | 727 → 861 | 0 — os dois botões que a coluna tem, "Editar" (743→786) e "Desativar" (787→853), estão inteiramente fora da tela. Não há terceiro: excluir usuário não é oferecido em tela nenhuma, no celular nem no computador |
Perfis de Acesso (/roles). São duas tabelas, "Perfis do sistema" e "Personalizados", de 410px cada.
| Coluna | Onde está | Quanto se vê |
|---|---|---|
| Nome | 17 → 154 | inteira |
| Descrição | 154 → 272 | inteira, espremida em 118px — uma frase de uma linha vira seis, e cada perfil ocupa ~150px de altura |
| Tipo | 272 → 354 | inteira |
| Ações | 354 → 427 | o cabeçalho aparece por 5px; os botões estão em 381→411, fora dos 375px da tela |
Novo usuário (/users/create) e Editar usuário (/users/{id}/edit) — as duas medidas, e as duas cabem. Nenhum controle passa de 375px em nenhuma das duas. Novo: quatro campos empilhados e rodapé com Cancelar/Salvar preso embaixo. Editar: dois painéis que empilham, Nome mais E-mail desabilitado, e um painel "Perfis de acesso" com chips removíveis, seletor e botão numa linha. O layout dos formulários de usuário não é o problema.
Detalhe do perfil (/roles/{id}). É onde a lista completa de permissões se lê, agrupada por recurso, e tem a mesma quebra: tabela de 570px no painel de 359. "Recurso" (17→316) cabe, "Ação" (316→459) fica cortada ao meio e "Descrição" (459→587) fica inteiramente fora. A tela que responde "o que este perfil dá?" mostra, no celular, o nome do recurso e metade do verbo.
Editar perfil (/roles/{id}/edit). O quadro de permissões quebra: o FormControlLabel tem display: block, então o checkbox vai para cima do rótulo em vez de ficar ao lado, e cada permissão ocupa ~73px de altura.
Quem a lista traz. Medido no mesmo ambiente, com a mesma sessão: 17 usuários, dos quais 10 têm partner_agency_id — são de imobiliária, não da garantidora. O único inativo da lista é um deles. (A tabela acima diz 18 porque foi lida um pouco antes, com o usuário que a própria verificação do role: "" criou e depois apagou.) Não há coluna, marca ou filtro que separe os dois públicos.
O que não funciona em viewport nenhum, medido¶
Executado contra o ambiente de desenvolvimento, com sessão de gestor@e2e.test (ADMIN_TENANT):
| O que se tentou | Resultado |
|---|---|
POST /users com role: "OPERATOR" (o que a tela manda ao escolher um perfil) | 403 — "Perfis do sistema não podem ser alterados" |
POST /users sem a chave role (o que a tela manda com o campo em branco) | 403 — o padrão do contrato é OPERATOR, também de sistema |
POST /users com role: "" | 201 — o único caminho que passa, e nenhuma tela o usa |
GET /users · ?is_active=true · ?is_active=false | 18, 18 e 18 usuários, com o mesmo 1 inativo dentro dos três. A rota não declara o parâmetro |
O caminho, ponta a ponta — o caso que mais dói¶
Sábado, o sócio recebe a mensagem de que uma pessoa foi desligada e o acesso precisa cair.
- Abre o app no celular, toca em Mais, depois em Equipe.
- Rola a lista até reconhecer a pessoa pelo nome — não há busca.
- Procura o botão de desativar. Não existe na tela. Rola até o fim e volta; tenta arrastar para o lado, e a página não anda.
- Sai do sistema. Manda mensagem para alguém que esteja perto de um computador, ou espera até segunda-feira e faz ele mesmo.
- Quando alguém finalmente abre no computador, desativa em dois toques — a ação sempre foi trivial; o que faltava era chegar até ela. E o sistema não guarda nada disso: nem a decisão, nem a execução, nem a data.
Onde quebra: no passo 3, e quebra em silêncio. A tela não diz "esta ação não está disponível no celular"; ela simplesmente não mostra a coluna, e quem olha conclui que o produto não faz isso — não que o produto faz e escondeu.
As outras tarefas, e onde cada uma para¶
- Conferir quem tem acesso: passos 1 e 2 iguais. No 3, a coluna de Perfis aparece por 14px e a de Status por 0 — as duas respostas por pessoa somem no celular. E varrer a equipe ("quem está inativo?") não funciona em aparelho nenhum: o filtro devolve a lista inteira nas três opções. Quando devolvesse, ainda viria misturada com dez usuários de imobiliária, cujo "inativo" quer dizer convite pendente.
- Cadastrar alguém novo: o
+abre, o formulário preenche, e salvar dá 403. A pessoa lê uma mensagem sobre perfis de sistema numa tela em que não mexeu em perfil nenhum, e não tem o que fazer a respeito. Para no passo de salvar, em qualquer aparelho. - Corrigir nome ou perfis de alguém: o formulário de edição funciona; o botão que leva a ele está fora da tela no celular. Para no passo de achar o botão.
- Achar o e-mail de um colega: funciona, e é a única tarefa que o celular resolve inteira — ao custo de rolar a lista até encontrar.
O que se faz quando a tela não resolve¶
Não há operação para observar — a produção não tem cliente (seção 2), e o único uso real das duas telas é o do próprio time no ambiente compartilhado. Então o que segue é o que o produto obriga, e não prática medida: sem a ação na tela, as saídas possíveis são exatamente duas — pedir a quem estiver na frente de um computador ou adiar até chegar a um. As duas foram citadas por quem pediu a spec como o que já acontece dentro do time.
Para o cadastro, o computador não resolve — mas há três voltas, e vale registrar as três porque a primeira acontece dentro do produto:
- Criar um perfil personalizado e escolhê-lo. Funciona (201, verificado), e é o que a mensagem de erro insinua. Custa montar um conjunto de permissões à mão e deixa no tenant um perfil gêmeo de um que já existe, criado só para destravar um cadastro.
- Pedir ao
ADMIN_GLOBAL, que não é barrado pela guarda — e que é da plataforma, não da garantidora. - Chamar a API direto com
role: "", que é o único corpo que passa e que nenhuma tela monta.
A primeira saída tem um efeito que se soma ao silêncio da auditoria: a decisão fica na conversa, fora do sistema, e a execução também não é gravada. Depois do fato, não há como saber quem desativou ou excluiu quem, nem quando — só que a conta não entra mais.
O que já se tentou¶
- Girar o aparelho. Em paisagem sobra largura para a coluna de Perfis. Ações continua fora — e é ela que carrega o que se veio fazer. Também não é solução que se ensine: depende do modelo e de o giro estar destravado.
- Pinçar para diminuir o zoom. O corte não é de zoom — é
overflow: hiddenno painel. Diminuir o zoom encolhe a página inteira e a tabela junto; as colunas continuam do lado de fora. - A opacidade da linha como sinal de inativo é a tentativa anterior de caber informação em pouco espaço, e não funciona: não tem rótulo, some no sol, e quem não conhece a convenção não a lê. O filtro Todos/Ativo/Inativo é a segunda tentativa, e ela nunca chegou a funcionar — a tela foi escrita para mandar o parâmetro, e a rota nunca foi escrita para recebê-lo.
- Em Perfis, o toque na linha. As linhas de perfil de sistema já levam ao detalhe; as de perfil personalizado, não. Está invertido em relação ao que serviria: perfil de sistema é só leitura — o detalhe dele nem oferece Editar —, e é o personalizado que teria o que fazer lá dentro.
4. Resultado esperado¶
A Equipe passa a administrar quem entra na garantidora, do aparelho que estiver na mão, e toda mudança de acesso deixa registro. Verificável assim:
- Um
ADMIN_TENANTcadastra um usuário e o cadastro entra — com perfil escolhido ou sem. Hoje as duas formas devolvem 403, e é o defeito mais grave dos três: sem ele, a garantidora não põe ninguém para dentro. - O filtro de situação filtra. Escolher "Inativo" devolve só inativos. Hoje devolve a lista inteira, e é por isso que "esta pessoa ainda tem acesso?" não tem resposta em viewport nenhum.
- Tirar o acesso de alguém acontece pelo celular, dentro do app, sem intermediário — e passa a ter as duas formas: desativar, que já existe e está fora do alcance, e excluir, que hoje não existe em tela nenhuma e passa a ser oferecida ao lado dela (decisão do usuário, 2026-08-20).
- Toda mudança de acesso deixa registro, por qualquer caminho — ativar, desativar, excluir, e o perfil concedido tanto na edição quanto no cadastro. O registro é quem executou e quando, como o resto da auditoria do produto; por que alguém decidiu não é campo que exista em lugar nenhum, e esta spec não inventa um. Hoje só a edição de perfil grava, o que é pior que não gravar nada: quem olha a auditoria e não acha conclui que ninguém concedeu.
- A Equipe mostra a equipe. Quem é de imobiliária deixa de aparecer numa lista de "usuários internos" — e some com ele a ambiguidade do "Inativo", que hoje pode ser acesso revogado ou convite de parceira não aceito (decisão do usuário, 2026-08-20).
- As quatro perguntas que a Equipe existe para responder — quem é, qual o e-mail, que perfil tem, está ativo? — são respondidas na tela, nos dois viewports. Hoje duas delas somem em 375px.
- Editar um usuário deixa de exigir computador, e a lista de Perfis passa a ser consultável no celular — que é onde se responde "o que este perfil dá?" antes de conceder um perfil a alguém. Montar um perfil continua sendo tarefa de mesa, por decisão registrada: o formulário de permissões não vira mobile.
- Achar uma pessoa deixa de ser rolar a lista inteira.
- Nas telas que entram — as duas listas e o formulário de usuário — nenhum controle fica fora dos 375px. É a medida objetiva de que o conteúdo coube, e é a que carrega peso: "não rola na horizontal" já é verdade hoje, e é verdade justamente porque o painel corta o que não cabe em vez de deixar alcançar.
- Marcar uma permissão acerta a permissão pretendida — no celular e no computador. É correção de defeito, não de layout: o quadrado fica ligado ao rótulo certo.
5. Fora de escopo¶
- Trocar o e-mail de um usuário. Decisão do usuário, 2026-08-20: o endereço é o login, e mexer nele arrasta unicidade, sessão aberta e aviso à pessoa.
- Auditoria do que não é acesso. Criar usuário e mudar o nome continuam sem registro. O que entra é o que muda quem alcança o quê: ativar, desativar, excluir, e a concessão de perfil por qualquer caminho.
- As telas de
/admin/*(plataforma). São doADMIN_GLOBAL, têm outro público e outro volume, e a fronteira plataforma × cliente é assunto que a spec 0040 acabou de estreitar. Se elas também estiverem quebradas no celular, é outra spec. - Alargar acesso. Nenhuma permissão nova, nenhum papel novo, e o
rbac_seed.pynão muda. Consertar o 403 do cadastro não é alargar: o que falha hoje é uma recusa que o gestor não deveria receber.
O que a Parte III acabou mudando, e é o contrário de alargar (decisões de 2026-08-20, seção 9): ninguém concede nem monta perfil que dê mais do que ele tem; convidar imobiliária passa a exigir papel de sistema; perfil em uso não se exclui; e nome de papel de sistema deixa de conceder poder (RN2d, que fecha uma escalada que está no ar). Quase todas estreitam.
A exceção é uma, e é deliberada: trocar a lista de recursos não-delegáveis pela contenção alarga um pedaço —
tenants:readetenants:updatepassam a caber num perfil personalizado, e com a segunda alguém alcançaPATCH /onboarding/tenant. Está na RN2c com a tabela e o CA35a, e é decisão registrada: barrar por recurso era efeito colateral, não regra. - O convite de membro da equipe por e-mail. Existe na API (POST /onboarding/users, que emite um código do tipoUSER_INVITE) e nenhuma tela do app o chama — o cadastro pela Equipe cria a pessoa com senha definida na hora. Ligar as duas coisas é outra spec. - Redesenhar a tela de Equipe no desktop. O que a spec toca no desktop é o que os defeitos e a paridade com as outras listas arrastarem, e nada além. - Administrar o acesso de usuário de imobiliária. Eles saem da lista da Equipe; quem governa esse acesso é a parceria, e a tela de Imobiliárias é onde ela vive. Esta spec não constrói a administração deles lá — só para de misturá-los aqui. - Excluir não passa a liberar o e-mail. A diferença entre desativar e excluir é uma só, e é de lista: desativar mantém a pessoa visível e marcada, excluir a tira de vista. O registro continua, o índice de e-mail continua único, e recadastrar com o mesmo endereço continua esbarrando nele (decisão do usuário, 2026-08-20).Para a Parte III, e não para esta seção: hoje esse esbarrão é feio.
get_by_emailignora excluídos e a coluna é única sem índice parcial, então oConflictErroramigável não dispara e a violação cai no tratador genérico — 500. Ninguém alcança isso hoje porque nenhuma tela exclui usuário; esta spec torna o caminho alcançável pela primeira vez. - O formulário de perfil não vira mobile. Decisão do usuário, 2026-08-20: montar um perfil é tarefa de mesa. Da tela de Perfis entram a lista (que se consulta antes de conceder um perfil pela Equipe) e a correção do checkbox, que é defeito de concessão de acesso, não de viewport. - Perfis de sistema continuam sendo só leitura. É o que as guardas depermission_servicejá fazem, e nada aqui muda: no celular como no computador, o que se edita é perfil personalizado. - Busca e paginação em Auditoria e Tipos de Pendência, que também não têm. São outras telas, com outro volume e outro dono.
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 | 1 | 3 | 4 | REPROVADO |
| 2 | 2 | 2 | 6 | REPROVADO |
| 3 | 1 | 1 | 3 | REPROVADO |
| 4 | 2 | 1 | 1 | REPROVADO |
| 5 | 0 | 3 | 3 | REPROVADO |
| 6 | 0 | 3 | 2 | REPROVADO |
| 7 | 0 | 0 | 5 | APROVADO |
A rodada 1 reprovou por três afirmações sobre o produto de hoje que não batiam com o código, e é o achado mais barato de fazer e o mais caro de deixar passar: uma Parte I que descreve errado o que já existe desenha contra um problema que não é o que está lá.
ANALYSTeVIEWERtambém têmusers:liste abrem a Equipe — a spec dizia que só oADMIN_TENANTa enxergava, e com isso perdia dois terços de quem sente a metade de leitura.- O filtro Todos/Ativo/Inativo fica fora da tabela e sobrevive em 375px: "está ativo?" já tem saída hoje, ao custo de duas travessias. A dor era menor do que o texto dizia, e a de "qual perfil?" é que não tem saída nenhuma.
- O botão de criar usuário funciona no celular (o
PageHeaderporta a ação principal para a barra do topo). A spec afirmava o contrário numa seção e o correto na outra.
Mais: o contorno do WhatsApp estava escrito como prática observada num produto que a própria spec declara sem operação; virou "o que o produto obriga", com a origem dita. E quatro Minor de vocabulário e de fato — roles:* (o ADMIN_TENANT tem roles:list/read; escrita é por papel de sistema), as duas tabelas de /roles, "única lista sem busca" (Auditoria e Tipos de Pendência também não têm) e uma atribuição errada à spec 0016 sobre o maximum-scale.
A rodada 2 achou dois buracos no produto que a spec descrevia como resolvidos, e os dois saíram do laço para o usuário decidir:
- Desativar usuário não grava auditoria. A spec afirmava que "já existe e já grava", punha isso em fora de escopo, e ainda apoiava nele um resultado esperado.
UserService.updatenão instanciaAuditRepository. Decisão do usuário (2026-08-20): entra no escopo — ativar e desativar passam a registrar. Concessão de perfil, não. - O e-mail não é editável em lugar nenhum, nem no computador:
UserUpdateaceita sónameeis_active. A dor descrevia uma espera que não terminava. Decisão do usuário: fica fora — o endereço é o login, e mexer nele é outra feature.
A rodada 3 pegou um erro que a própria spec cometia nos dois sentidos: ela dizia, na seção 1, que perfil grava auditoria, e na seção 5 que não grava. Quem tem razão é a seção 1 — assign_role_to_user e revoke_role_from_user gravam ASSIGN_USER/REVOKE_USER. O buraco real é mais estreito e mais perigoso: o perfil escolhido no cadastro entra por vínculo direto, sem registro, então a auditoria de concessão cobre um caminho e não o outro. Decisão do usuário (2026-08-20): entra no escopo — o que passa a gravar é toda mudança de acesso, por qualquer caminho.
Na mesma rodada, a tela de editar usuário foi medida (a spec estendia a ela o veredicto de "funciona" tomado na de criação, e são layouts diferentes): também está adequada em 375px — dois painéis que empilham, chips de perfil, seletor e botão numa linha, nada fora dos 375px. Caíram ainda os 17 usuários do dev-remote como evidência de tamanho de equipe (são fixtures de e2e) e a metade sem peso do resultado 6 ("não rola na horizontal" já é verdade hoje — é o overflow: hidden que impede).
A rodada 4 saiu do texto e foi executar, e achou dois defeitos que a spec contava como "o que já funciona". É o achado mais caro do portão, e o mais barato de fazer:
- O
ADMIN_TENANTnão cria usuário nenhum.POST /usersrecusa perfil de sistema para quem não éADMIN_GLOBAL, e o padrão do contrato (OPERATOR) também é de sistema — então escolher perfil dá 403 e deixar em branco dá 403, que é justamente o que a tela manda. Confirmado por execução: 403, 403, e 201 só comrole: "", que nenhuma tela usa. - O filtro Todos/Ativo/Inativo não filtra. A tela manda
is_active, a rota não declara o parâmetro. Medido: 18 usuários nas três opções, com o mesmo inativo dentro das três.
Decisão do usuário (2026-08-20): os dois entram nesta spec — sem o primeiro, a jornada de cadastrar pelo celular leva a um botão que sempre falha; sem o segundo, "está ativo?" não tem resposta em viewport nenhum. E excluir usuário também passa a gravar auditoria, junto com ativar e desativar: é a remoção total do acesso, e sem ela o buraco continuaria aberto pelo caminho mais grave.
A rodada corrigiu ainda a extensão indevida do veredicto "funciona" da tela de criação para a de edição (as duas foram medidas, e as duas cabem), e a busca — que era resultado esperado sem ser custo declarado — virou as duas coisas.
A rodada 5 achou dois contornos e uma capacidade que não existe, e as três mudaram o escopo:
- O 403 do cadastro tem volta dentro do produto — criar um perfil personalizado e escolhê-lo passa (201, verificado). A spec dizia que "não há o que fazer a respeito"; há, ao custo de montar permissões à mão e ficar com um perfil gêmeo no tenant.
- Excluir usuário não existe em tela nenhuma —
usersApitemlist,get,createeupdate, e mais nada. Duas afirmações caíram junto: "apagar e recriar" nunca foi conserto disponível para o e-mail errado, e excluir não era, como o texto sugeria, uma ação existente fora do alcance no celular. Decisão do usuário (2026-08-20): a tela ganha a ação, ao lado de Desativar. - Perfis de Acesso entrava sem consequência escrita. Ganhou a dela — consultar "o que este perfil dá?" é a pergunta que antecede conceder um perfil pela Equipe —, e ganhou fronteira: decisão do usuário, o formulário de perfil não vira mobile; da tela entram a lista e a correção do checkbox, que é defeito de concessão de acesso e não de viewport.
A rodada 6 achou o que talvez seja o maior problema da tela, e ele não é de viewport: a lista da Equipe traz também os usuários de imobiliária. O convite de parceira cria User com o tenant_id da garantidora e um partner_agency_id, e list_by_tenant não separa. Medido: 10 dos 17. A consequência não é estética — é a ambiguidade do "Inativo", que pode ser acesso revogado ou convite pendente, exatamente sobre a pergunta que a spec promete responder. Decisão do usuário (2026-08-20): a Equipe passa a mostrar só quem é da garantidora.
Na mesma rodada: excluir e desativar fazem quase a mesma coisa — o DELETE é soft delete e nenhum dos dois libera o e-mail, cujo índice é único. Decisão do usuário: a diferença é de lista — desativar mantém a pessoa visível e marcada, excluir a tira de vista —, e a tela precisa dizer isso nos dois botões. E os resultados esperados foram alinhados à fronteira do formulário de perfil, que a rodada anterior tinha fechado e que o item 6 ainda contrariava.
As demais foram medidas em vez de discutidas: as coordenadas reais de cada coluna em 375px entraram na seção 3 (uma coluna inteira sobrevive, Perfis aparece por 14px, os botões de /users estão em 743→853 e os de /roles em 381→411 — todos fora da tela), o detalhe de perfil de sistema não tem Editar, o giro do aparelho recupera Perfis mas não Ações, e três atribuições erradas caíram (spec 0035 para a leitura de perfil de sistema, tenant-invite como convite de equipe, roles:list como exclusiva do ADMIN_TENANT).
Resultado: aprovada na 7ª rodada — 0 Critical, 0 Important, 5 Minor.
O laço rodou sete vezes, e não é o caso da spec 0036. Lá ele ficou preso no mesmo ponto, e o que travava eram decisões de negócio. Aqui nenhuma rodada repetiu achado: cada uma achou uma coisa nova e verdadeira sobre o produto, e quatro delas mudaram o escopo. O sinal não é de spec mal escrita — é de uma área que estava mais quebrada do que o pedido supunha. O pedido era "adequar a tela da equipe no mobile"; o que apareceu no caminho foi que o gestor não consegue cadastrar ninguém, que o filtro de situação nunca filtrou, que tirar acesso não deixa rastro, e que a lista traz mais gente de imobiliária do que da equipe.
O que virou método, e vale para as próximas: as três primeiras rodadas discutiram texto; a partir da quarta o revisor passou a executar contra o ambiente, e foi aí que os defeitos apareceram. Medir layout já era hábito (as coordenadas de cada coluna estão na seção 3); medir comportamento não era.
Pendências aceitas:
- A dor da busca encolhe com a separação dos dois públicos — de 17 pessoas para 7. A busca continua no escopo, e a razão principal passa a ser a paridade: é a única lista de negócio do produto sem ela.
- O 500 do recadastro com e-mail de usuário excluído está anotado no fora de escopo como material da Parte III — esta spec é a que torna o caminho alcançável.
- A visão do
ADMIN_GLOBALsobre/users(que não filtra tenant) precisa de uma decisão na Parte III sobre o corte porpartner_agency_id. Não é da Parte I.
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¶
Ponto de entrada: os mesmos de hoje, e nenhum muda. "Equipe" e "Perfis de Acesso" ficam sob ADMINISTRAÇÃO na navegação lateral; no celular, chegam pela aba Mais. Ninguém navega até a Equipe para "adequar viewport": a pessoa vai lá porque precisa tirar, dar ou conferir um acesso, e o aparelho é o que estiver na mão.
Jornada, caso feliz — e são quatro, porque são quatro tarefas. Todas começam do mesmo lugar.
Tirar o acesso de alguém (a de sábado, a que motivou a spec):
- Abre a Equipe. A lista mostra só quem é da garantidora — sete pessoas, não dezessete.
- Digita as primeiras letras do nome na busca. A lista se reduz enquanto ela digita.
- Na linha (ou no cartão) da pessoa, aciona Desativar.
- Confirma num diálogo que diz o que vai acontecer e o que não vai: a pessoa deixa de entrar, continua na lista, e dá para reativar.
- A lista volta com a pessoa marcada Inativo, no mesmo lugar — ou ela sai da lista, se o filtro escolhido for "Ativo". Um toast confirma o que aconteceu (o padrão do app para sucesso, canto inferior, some sozinho), e é ele que impede a pessoa de achar que perdeu o registro quando a linha some por causa do filtro. Em 375px ele aparece acima da
ListPagination, que é fixa no rodapé — os dois dividem o mesmo canto, e o toast não pode cobrir a navegação de página. - Se a intenção era tirar de vista de vez — alguém que não volta —, a ação é Excluir, e o diálogo dela diz a diferença: some da lista, e não se desfaz pela tela. Depois de confirmar, a linha desaparece em qualquer filtro, e o aviso curto diz que foi excluída — não some em silêncio.
Conferir quem tem acesso, e com quê:
- Abre a Equipe.
- Escolhe Inativo no filtro de situação. A lista passa a mostrar só os inativos — hoje ela devolve todo mundo nas três opções, e é o defeito que faz esta pergunta não ter resposta.
- Cada pessoa mostra nome, e-mail, perfil e situação, sem nada fora da tela.
Pôr alguém para dentro:
- Aciona "Novo usuário".
- Preenche nome, e-mail e senha, e escolhe um perfil — inclusive um perfil de sistema, que é o caso normal. Hoje isso devolve 403, e deixar em branco também.
- Salva. A pessoa entra, com o perfil escolhido, e a concessão fica registrada. A tela volta para a lista, com o toast confirmando — e a pessoa nova aparece ali, o que é a confirmação que importa. É o que a tela já faz hoje ao salvar; o que muda é que agora salvar dá certo.
E "em branco" passa a querer dizer o que o campo promete. O rótulo já diz "Opcional — pode ser atribuído depois", e é isso que passa a acontecer: quem não escolhe perfil cria alguém sem perfil nenhum, que entra no sistema e não alcança nada até receber um pelo Editar (decisão do usuário, 2026-08-20). Hoje o padrão do contrato é OPERATOR — um perfil de sistema —, e é por isso que deixar em branco também dá 403. Conceder OPERATOR calado seria pior que o 403: a auditoria registraria uma concessão que ninguém fez, que é a queixa da seção 1.
Entender um perfil antes de concedê-lo:
- Abre Perfis de Acesso e lê a descrição de cada um, no celular como no computador. É a pergunta que antecede a anterior: "o que este perfil dá?"
- Se for um perfil personalizado e for preciso ajustar as permissões, isso continua sendo tarefa de computador — mas as caixas marcam o que dizem marcar, que hoje não é verdade em aparelho nenhum.
Custo de uso: nada de novo a preencher, e uma tarefa que já era cara continua sendo.
Três das quatro jornadas não têm digitação nenhuma além da busca: tirar acesso, conferir e entender um perfil são toque e leitura. O sistema já sabe quem está executando, em que garantidora, quais perfis existem e qual é a situação atual de cada pessoa. As duas ações destrutivas pedem confirmação — que já existe hoje para Desativar —, e nenhuma delas pede justificativa: o registro de auditoria guarda quem e quando, que é o que a Parte I pediu, e exigir um texto por desativação transformaria um ato de segurança em formulário.
A quarta é outra história, e o celular não a barateia. Cadastrar alguém pede nome, e-mail e senha digitados no aparelho — e, como o convite por e-mail está fora de escopo (seção 5), quem cadastra ainda inventa uma senha e a transmite por fora do sistema. Esta spec não reduz esse custo; ela só faz o botão Salvar funcionar. É o caso em que "dá para fazer do celular" quer dizer "é possível", e não "é confortável" — e é uma diferença que vale estar escrita, porque a jornada que mais se beneficia da spec (tirar acesso) é justamente a que não digita nada.
O que a jornada não resolve, e é decisão registrada: tirar o acesso de um usuário de imobiliária. Ele sai da lista da Equipe, e a administração dele não é construída nesta spec (seções 1 e 5).
7.1 Desktop¶
A Equipe ganha a barra de filtros que todas as outras listas têm, e mais uma ação na linha. A tabela continua sendo a tabela — não vira DataGrid, e as colunas são as mesmas.
Acima do painel, a FilterBar — o mesmo componente de Imobiliárias, Inquilinos e Imóveis:
- Busca (
SearchField), que cresce e ocupa o espaço: por nome ou e-mail. - Situação (
FilterSelect): Todos · Ativo · Inativo. Substitui oToggleButtonGroupde hoje, e é o que a paridade arrasta: a busca precisa morar em algum lugar, e o lugar dela neste app é aFilterBar, onde o filtro éFilterSelect. O comportamento é o mesmo três estados; o que muda é o controle, e o que muda de verdade é que ele passa a filtrar. - A contagem, à direita, como nas outras (
7 usuários).
Sem chips de filtro aplicado e sem "Limpar filtros". A FilterBar os oferece, e as listas que os usam têm três ou mais filtros combináveis. Aqui há um filtro e uma busca, os dois visíveis o tempo todo e com o próprio estado à vista — repetir "Situação: Inativo" num chip logo abaixo do seletor que diz "Inativo" gasta altura para não informar nada.
Abaixo do painel, a ListPagination, também como nas outras. Hoje a tela carrega 200 de uma vez e conta em memória. A ordem é alfabética por nome — é como se procura uma pessoa numa lista de equipe, e é o que faz a promessa de "a pessoa continua no mesmo lugar" ser conferível. Ordenar por data de cadastro, que é o padrão do repositório, poria os recém-chegados no topo e mudaria a posição de todo mundo a cada admissão. Busca, filtro e paginação acontecem no servidor — é a regra que o docs/ui-patterns.md fixa para as listas, e é o que impede o recorte-do-acaso que Imobiliárias e Imóveis já pagaram: filtrar em memória sobre uma página filtra a página, não a lista. Mudar a busca ou o filtro volta para a primeira página.
A linha inativa deixa de ser 40% mais clara. A Parte I registra a opacidade como a tentativa que falhou de dizer "inativo" sem rótulo; com a coluna Status legível e o filtro funcionando, ela não acrescenta e atrapalha — no sol, uma linha esmaecida parece defeito de contraste. A linha volta ao peso normal, e quem diz a situação é o texto.
A coluna Perfis mostra o identificador técnico (ADMIN_TENANT, ANALYST), que é o mesmo que aparece no cartão de Perfis de Acesso e o que a tela já mostra hoje — é o vocabulário que atravessa as duas telas, e trocá-lo aqui obrigaria a pessoa a traduzir de uma para a outra.
A coluna Perfis passa a dizer "sem perfil" onde hoje mostra um travessão. Com a decisão de "em branco = sem perfil", isso deixa de ser borda e vira caso comum — e um travessão não distingue "não tem" de "não carregou".
Na coluna Ações, três botões em vez de dois: Editar, Desativar/Ativar e Excluir. São Button com rótulo, como os dois de hoje, e não ícones: numa coluna com uma ação destrutiva, a palavra vale mais que o glifo, e é a diferença entre "Desativar" e "Excluir" que a tela precisa deixar clara. Excluir é a única em vermelho.
A coluna cresce para caber os três — de 13% para 20% —, e quem cede a largura é "Usuário", que hoje tem 35% e é a coluna mais folgada da tabela: nome e e-mail empilhados sobrevivem bem em 28%. "Cadastrado em" não cede: com 12% ela já está no limite do próprio cabeçalho em caixa alta, e estreitá-la resolveria a quebra de linha de um lugar criando-a em outro. Não é redesenho: é a mesma tabela com outra proporção, e a alternativa — deixar 13% — põe os três rótulos em duas linhas.
Cada ação fica atrás da permissão dela, e não do conjunto — users:update para Editar e para Ativar/Desativar, users:delete para Excluir, users:create para "Novo usuário". As três são separadas na API, e perfil personalizado se monta permissão por permissão: quem tiver uma e não a outra recebe só a que tem. A coluna some inteira quando nenhuma das duas de linha existe, que é o caso do ANALYST e do VIEWER. Vale igual no rodapé do cartão em 375px.
Ninguém recebe as ações sobre a própria linha. A API recusa desativar e excluir a si mesmo, e a tela não oferece: na linha de quem está logado, a coluna Ações traz só "Editar". Vale igual no cartão do celular.
A linha passa a ser clicável, e leva ao mesmo lugar que o toque no cartão — o formulário de edição, para quem tem users:update. Hoje ela é inerte. É a mesma correção que a spec faz em Perfis e pelo mesmo motivo: a lista não pode responder ao clique de um jeito no computador e de outro no telefone. Para quem não pode editar, a linha continua inerte nos dois.
A tabela perde os usuários de imobiliária — dez das dezessete linhas de hoje, no ambiente de desenvolvimento. Isso não ganha aviso nem filtro para trazê-los de volta: a tela diz "Gerencie usuários internos" e passa a ser verdade. Quem procurar por alguém do canal e não achar tem a mesma experiência de procurar por alguém que não existe — e é uma consequência aceita da separação (seção 1).
Os dois diálogos de confirmação dizem coisas diferentes, porque as duas ações são diferentes e parecidas demais para não serem explicadas:
| Ação | O que o diálogo diz |
|---|---|
| Desativar | "{Nome} deixa de conseguir entrar. A pessoa continua na lista e pode ser reativada." |
| Ativar | "Ativar {Nome}?" — o diálogo de hoje, sem mudança: a ação devolve o que a outra tirou, e não precisa explicar mais que isso |
| Excluir | "{Nome} deixa de conseguir entrar e sai da lista. Não dá para desfazer por aqui, e o e-mail dela não fica livre para um cadastro novo. O histórico do que ela fez continua no sistema." |
Duas frases do segundo existem para evitar becos, e as duas foram escolhidas por serem mais baratas que a explicação depois:
- "o e-mail dela não fica livre" — porque trocar e-mail está fora de escopo (seção 5) e o endereço continua ocupado pelo registro excluído. Quem for recadastrar a mesma pessoa precisa de outro endereço, e é melhor saber disso antes de excluir do que ao tentar cadastrar.
- "o histórico continua no sistema" — excluir não apaga análises, decisões nem garantias assinadas por aquela pessoa.
E se alguém tentar mesmo assim recadastrar com o e-mail de um usuário excluído, o que se lê é a mensagem que o produto já tem para e-mail repetido — "Este e-mail já está vinculado a outro usuário na plataforma. Utilize um e-mail diferente." — no topo do formulário, com o campo marcado. A saída é o próprio texto: outro endereço.
Perfis de Acesso, no desktop, quase não muda — as duas tabelas, as quatro colunas e as ações continuam onde estão. Uma coisa muda, e é para não criar divergência entre viewports: hoje a linha de perfil de sistema leva ao detalhe ao ser clicada e a de personalizado não — está invertido, porque perfil de sistema é só leitura e é no personalizado que há o que fazer lá dentro. As duas passam a levar, como o cartão do celular passa a levar. Sem isso, a mesma lista responderia ao clique de um jeito no computador e de outro no telefone. A exceção é o quadro de permissões do formulário, e ela não é de tamanho de tela: medido em 1440px, o checkbox está 30px acima do rótulo e cada permissão ocupa 73px, exatamente como em 375px. O quadrado passa a ficar ao lado do texto que ele marca, nos dois viewports — é correção de um defeito que faz conceder acesso errado.
O detalhe do perfil, no desktop, não muda — a tabela de três colunas continua ali, e ela cabe. O bloco por recurso é a tradução de 375px, como o cartão é a tradução da linha de tabela nas listas.
Formulário de usuário: sem mudança de layout. Ele já está certo nos dois viewports. O que muda é que salvar funciona: o campo Perfil deixa de ser uma escolha que garante 403.
7.2 Mobile (375px)¶
A Equipe vira cartão, com o desenho que o próprio produto já usa para esta entidade. Há duas traduções de tabela para cartão no app — MobileListCard, de Imobiliárias, e RecordCard, de Inquilinos e Imóveis. A Equipe segue o RecordCard, e o motivo não é preferência: a lista de usuários já existe em cartão no produto, em /admin (a visão da plataforma), montada com RecordCard, USER_STATUS, busca e FilterPill. Duas telas da mesma entidade com desenhos diferentes seria divergência criada de graça — e RecordCard é o que o components/data/RecordCard.prompt.md descreve como a tradução canônica de linha em bloco.
No topo, a MobileFilterBar: busca de largura cheia ("Buscar por nome ou e-mail…") e uma FilterPill de situação (Todos · Ativo · Inativo), que rola na horizontal e fica sólida quando escolhida. A contagem vem embaixo, discreta.
Cada pessoa é um RecordCard, com o mapeamento que o padrão define:
| Na tabela | No cartão |
|---|---|
| Usuário (nome) | o título |
| Usuário (e-mail) | 1ª linha de meta, com ícone de envelope |
| Perfis | 2ª linha de meta — separados por vírgula; quem não tem, "sem perfil" |
| Status | o selo no topo à direita (USER_STATUS: Ativo · Inativo) — a informação que hoje se perde |
| Cadastrado em | não aparece. Não é uma das quatro perguntas da seção 4, e o padrão limita o cartão a três linhas de contexto para a lista continuar sendo varrível |
| Ações | rodapé: Desativar/Ativar e Excluir, alvos de 44px, com rótulo — pelo mesmo motivo do desktop: entre duas ações parecidas e uma delas destrutiva, o glifo sozinho não distingue |
O avatar de iniciais coloridas da tabela de hoje fica: é o que dá varredura à lista, e o RecordCard não tem slot de avatar, então ele entra como o primeiro elemento do título.
O corpo do cartão é tocável e leva ao formulário de edição, que já cabe em 375px — para quem tem users:update. Não há lápis no rodapé — e isso é uma diferença de propósito em relação ao desktop, onde a linha clicável e o botão "Editar" convivem. Em 375px o rodapé tem três alvos de 44px no máximo antes de o cartão virar barra de botões, e as duas ações que precisam estar lá são as que não têm outro caminho; no desktop o botão não disputa espaço com nada e serve a quem prefere clicar num alvo nomeado a descobrir que a linha inteira é clicável.
Para quem só lê — ANALYST e VIEWER —, o cartão não é tocável e o rodapé não existe. Eles abrem a Equipe (têm users:list), e não há tela de detalhe de usuário no produto: /users/:id não é rota, e o único destino do toque seria o formulário de edição, que a API vai negar ao salvar. Um cartão inerte é a resposta certa aqui — a informação que eles vieram buscar está no próprio cartão, e não há segunda tela para onde mandá-los.
Abaixo da lista, a ListPagination, irmã do bloco de conteúdo e não filha — a mesma estrutura de Inquilinos, para a barra ficar no rodapé mesmo com lista curta.
Perfis de Acesso também vira cartão, e mantém as duas seções ("Perfis do sistema" e "Personalizados") como dois blocos com seus títulos, na ordem de hoje:
| No cartão | O quê |
|---|---|
| Título | o nome amigável (Analista de Crédito) |
| 1ª linha | o identificador técnico (ANALYST), atenuado — é o que aparece na Equipe |
| 2ª linha | a descrição — em até duas linhas, com reticência só no fim da segunda |
| Canto superior direito | o selo Sistema ou Personalizado |
| Rodapé | Editar e Excluir, só nos personalizados |
A descrição precisa de duas linhas, e isso é uma mudança no componente. O RecordCard de hoje corta cada linha de contexto em uma só (noWrap), e é exatamente o que faria a descrição do perfil não responder a pergunta que motivou a entrada desta tela na spec (seção 1: "a Descrição é espremida em 118px"). Uma descrição cortada em uma linha é o mesmo defeito com outro nome. O RecordCard passa a aceitar linha de duas alturas — quem não pedir continua como está, então Inquilinos, Imóveis e a lista de usuários do /admin não mudam. Não é padrão novo: é o mesmo cartão, com a linha de contexto podendo ocupar duas alturas quando o conteúdo é uma frase e não um dado.
Vale também para a 2ª linha do cartão da Equipe: quem tem dois ou três perfis não cabe numa linha só.
O corpo leva ao detalhe do perfil (/roles/{id}) — e é o que corrige a inversão de hoje, em que só a linha de perfil de sistema navegava, justamente a que não tem o que fazer lá dentro.
E o detalhe também vira cartão (decisão do usuário, 2026-08-20), porque é lá que a jornada "entender um perfil antes de concedê-lo" termina — mandar o polegar para uma tabela cortada devolveria o problema da seção 1 com outro nome. Hoje ela é uma tabela de três colunas de 570px num painel de 359: a Descrição fica inteiramente fora, que é justamente a resposta que se foi buscar.
O cabeçalho da tela (nome, identificador, tipo, contagem de permissões) já empilha e cabe. O que muda é a tabela de permissões, e ela não vira RecordCard: uma permissão não é um registro de lista, é um item de um agrupamento. Ela vira o que a tela já insinua no desktop — um bloco por recurso, com o nome do recurso como título e, embaixo, uma linha por ação: o verbo em negrito e a descrição abaixo dele, em corpo menor. Sem tabela, sem coluna, sem corte.
Os blocos nascem fechados, com a contagem no título (Análises de Crédito (6)) — um perfil com sessenta permissões abertas é uma tela de rolagem infinita, e quem abriu quer saber "este perfil mexe em garantia?" antes de "quais ações de garantia?".
O formulário de perfil não vira mobile, e isto é decisão registrada (seção 5). Em 375px ele continua sendo o que é: uma lista longa de permissões, com rolagem longa. O que muda é que o quadrado passa a ficar ao lado do rótulo — a correção que vale nos dois viewports. Não se esconde a tela nem se bloqueia o acesso a ela: quem abrir consegue usar, com esforço; o que a spec não faz é redesenhá-la para o polegar.
O formulário de usuário não muda — as duas telas foram medidas e cabem (seção 3).
Nada desta feature deixa de existir no celular. As quatro tarefas da seção 1 são completáveis em 375px, que é o ponto inteiro da spec. O PWA não é afetado: nenhuma rota nova, nenhum asset novo, nenhuma tela offline.
7.3 Estados¶
Lista vazia (Equipe). "Nenhum usuário encontrado", e — quando há filtro ou busca ativos — uma segunda linha dizendo o que fazer: "Tente mudar o filtro ou limpar a busca". É o comportamento de hoje, com a busca acrescentada à frase.
Busca sem resultado. Mesmo bloco acima. A busca não é apagada automaticamente: quem digitou quer ver o que digitou.
Carregando. "Carregando…" no lugar da lista, nos dois viewports — o que a tela já faz. Não se troca por esqueleto: a lista é curta e a troca seria piscada.
Erro ao carregar. A faixa vermelha no topo com Tentar novamente, que já existe. A barra de filtros continua na tela nos dois viewports — FilterBar no desktop, MobileFilterBar em 375px: quem errou a busca conserta ali mesmo, sem recarregar a página.
Sem permissão. Não há estado a desenhar: quem não tem a permissão da tela não vê o item no menu, e quem chegar pela URL cai no /unauthorized que o app já tem.
- Equipe:
OPERATOReAGENCY_USERnão a veem.ANALYSTeVIEWERveem e não recebem ação nenhuma de escrita — nem "Novo usuário", nem coluna de Ações, nem rodapé no cartão, nem toque que leve a algum lugar. A tela não oferece o que a API vai negar. - Perfis de Acesso: só o
ADMIN_TENANTa vê.ANALYSTeVIEWERnão têmroles:list, e para eles ela se comporta como para os outros dois — fora do menu.
Quem foi cadastrado sem perfil, ao entrar pela primeira vez. É estado novo e normal — a decisão de "em branco = sem perfil" o cria —, e hoje ele leria "Você não tem permissão. Sua conta não tem acesso a esta página.", que soa como defeito do sistema e não como um passo que falta. A tela de recusa ganha uma variante para quem não tem perfil nenhum: "Sua conta ainda não tem um perfil de acesso. Peça a um gestor da garantidora para atribuir um." (decisão do usuário, 2026-08-20).
E os botões dela mudam nesta variante. A tela de recusa oferece hoje "Voltar" e "Ir ao painel"; para quem não tem perfil nenhum, "Ir ao painel" navega para /, que o roteador redireciona para uma rota gateada, que o devolve para esta mesma tela — o único botão de destaque é um laço. Nesta variante ficam "Sair" (que é a única coisa que ele pode fazer com proveito, e o traz de volta ao login quando o perfil chegar) e nada mais. Não se inventa um botão que resolva: nenhum resolve, e prometer o contrário é pior que a frase.
E é aqui que ele aterrissa, direto do login — não passa por tela nenhuma antes, porque não alcança nenhuma.
E o outro lado disso é dito no cadastro: deixar o Perfil em branco mostra, abaixo do campo, que "a pessoa entra e não alcança nada até receber um perfil". É a mesma informação, dita a quem ainda pode decidir.
Recusa ao desativar, excluir ou salvar. A mensagem da API, em português, no lugar onde a ação foi acionada: dentro do diálogo para Desativar e Excluir (uma faixa no corpo da página ficaria atrás do backdrop), e no topo do formulário para o cadastro. Nunca um número de status — é a lição do fix 0010, onde um 413 chegou à tela como "Erro 413".
A pessoa tenta desativar ou excluir a si mesma. A API recusa ("Você não pode excluir sua própria conta" já existe). A tela não oferece, nos dois viewports: na linha da tabela sobra só "Editar", e no cartão o rodapé não aparece. É o mesmo princípio de não convidar ao 403.
O cadastro que hoje falha. É o estado que some: escolher um perfil e salvar passa a entrar. Enquanto não entrava, a pessoa lia "Perfis do sistema não podem ser alterados" numa tela em que não mexeu em perfil nenhum — a mensagem mais confusa do produto hoje.
O filtro que hoje não filtra. Também some como estado: escolher "Inativo" e receber a lista inteira deixa de acontecer.
Os estados de Perfis de Acesso, que são outros. A tela tem dois blocos e eles se comportam diferente:
- "Perfis do sistema" nunca está vazio — são os seis do seed. Não precisa de estado de vazio.
- "Personalizados" vazio é o caso comum, e é o que a garantidora vê no primeiro dia. O bloco continua na tela, com o título, e no lugar dos cartões a frase "Nenhum perfil personalizado criado". Sumir com o bloco esconderia de quem nunca criou um que a coisa existe — e criar um perfil é justamente o que destrava o cadastro hoje.
- A contagem ao lado do título (
Perfis do sistema 6) só aparece quando a lista respondeu; antes disso não há número, porque0antes da resposta seria mentira. - Carregando e erro são os mesmos da Equipe, e valem para a tela inteira, não por bloco: "Carregando…" no lugar dos dois blocos, e a faixa vermelha com Tentar novamente no topo.
Os estados do detalhe do perfil. Herda os da lista, com uma diferença: carregando troca os blocos por "Carregando…", mas o cabeçalho da tela (nome, identificador, tipo) fica — é o que a pessoa veio conferir e ele chega primeiro. Erro é a mesma faixa vermelha com Tentar novamente. E há um estado próprio: perfil sem permissão nenhuma, alcançável num personalizado recém-criado — "Nenhuma permissão vinculada a este perfil", com a segunda linha dizendo o que isso quer dizer na prática: "quem tiver este perfil não alcança nada."
Recusa ao excluir um perfil que está em uso. A API recusa, e a mensagem aparece dentro do diálogo de confirmação, que continua aberto — mesma regra da Equipe, e pelo mesmo motivo: uma faixa no corpo da página ficaria atrás do backdrop.
O que a auditoria passa a mostrar. Os eventos novos aparecem na tela de Auditoria que já existe: os três de usuário sob o tipo user, que já é um dos filtros, e os de concessão de perfil sob role, que passa a ser um deles. Cada linha traz quem executou e quando, e a frase que descreve o ato — "Usuário {nome} desativado", "Usuário {nome} reativado", "Usuário {nome} excluído", "Perfil '{perfil}' atribuído ao usuário {nome} na criação", "Perfil '{perfil}' concedido a {nome} por convite" e — no evento que já existe — "Perfil '{perfil}' revogado de {nome}". Não há tela nova: o que faltava era o registro, não o lugar de lê-lo.
E esse lugar não é legível no celular — a Auditoria é uma tabela de cinco colunas no mesmo painel com
overflow: hidden, sem tratamento mobile, exatamente como a Equipe de hoje. Fica fora desta spec (decisão do usuário, 2026-08-20): é a mesma conversão de tabela em cartão, numa tela com outro volume e outros filtros. Consequência aceita e escrita: o quarto público da seção 2 — quem pergunta "quem tirou o acesso de fulano?" — passa a ter o que ler, e continua precisando de um computador para ler.
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 | 1 | 3 | 4 | REPROVADO |
| 2 | 2 | 2 | 6 | REPROVADO |
| 3 | 1 | 1 | 3 | REPROVADO |
| 4 | 2 | 1 | 1 | REPROVADO |
| 5 | 0 | 3 | 3 | REPROVADO |
| 6 | 0 | 3 | 2 | REPROVADO |
| 7 | 0 | 0 | 5 | APROVADO |
A rodada 1 reprovou por três afirmações sobre o produto de hoje que não batiam com o código, e é o achado mais barato de fazer e o mais caro de deixar passar: uma Parte I que descreve errado o que já existe desenha contra um problema que não é o que está lá.
ANALYSTeVIEWERtambém têmusers:liste abrem a Equipe — a spec dizia que só oADMIN_TENANTa enxergava, e com isso perdia dois terços de quem sente a metade de leitura.- O filtro Todos/Ativo/Inativo fica fora da tabela e sobrevive em 375px: "está ativo?" já tem saída hoje, ao custo de duas travessias. A dor era menor do que o texto dizia, e a de "qual perfil?" é que não tem saída nenhuma.
- O botão de criar usuário funciona no celular (o
PageHeaderporta a ação principal para a barra do topo). A spec afirmava o contrário numa seção e o correto na outra.
Mais: o contorno do WhatsApp estava escrito como prática observada num produto que a própria spec declara sem operação; virou "o que o produto obriga", com a origem dita. E quatro Minor de vocabulário e de fato — roles:* (o ADMIN_TENANT tem roles:list/read; escrita é por papel de sistema), as duas tabelas de /roles, "única lista sem busca" (Auditoria e Tipos de Pendência também não têm) e uma atribuição errada à spec 0016 sobre o maximum-scale.
A rodada 2 achou dois buracos no produto que a spec descrevia como resolvidos, e os dois saíram do laço para o usuário decidir:
- Desativar usuário não grava auditoria. A spec afirmava que "já existe e já grava", punha isso em fora de escopo, e ainda apoiava nele um resultado esperado.
UserService.updatenão instanciaAuditRepository. Decisão do usuário (2026-08-20): entra no escopo — ativar e desativar passam a registrar. Concessão de perfil, não. - O e-mail não é editável em lugar nenhum, nem no computador:
UserUpdateaceita sónameeis_active. A dor descrevia uma espera que não terminava. Decisão do usuário: fica fora — o endereço é o login, e mexer nele é outra feature.
A rodada 3 pegou um erro que a própria spec cometia nos dois sentidos: ela dizia, na seção 1, que perfil grava auditoria, e na seção 5 que não grava. Quem tem razão é a seção 1 — assign_role_to_user e revoke_role_from_user gravam ASSIGN_USER/REVOKE_USER. O buraco real é mais estreito e mais perigoso: o perfil escolhido no cadastro entra por vínculo direto, sem registro, então a auditoria de concessão cobre um caminho e não o outro. Decisão do usuário (2026-08-20): entra no escopo — o que passa a gravar é toda mudança de acesso, por qualquer caminho.
Na mesma rodada, a tela de editar usuário foi medida (a spec estendia a ela o veredicto de "funciona" tomado na de criação, e são layouts diferentes): também está adequada em 375px — dois painéis que empilham, chips de perfil, seletor e botão numa linha, nada fora dos 375px. Caíram ainda os 17 usuários do dev-remote como evidência de tamanho de equipe (são fixtures de e2e) e a metade sem peso do resultado 6 ("não rola na horizontal" já é verdade hoje — é o overflow: hidden que impede).
A rodada 4 saiu do texto e foi executar, e achou dois defeitos que a spec contava como "o que já funciona". É o achado mais caro do portão, e o mais barato de fazer:
- O
ADMIN_TENANTnão cria usuário nenhum.POST /usersrecusa perfil de sistema para quem não éADMIN_GLOBAL, e o padrão do contrato (OPERATOR) também é de sistema — então escolher perfil dá 403 e deixar em branco dá 403, que é justamente o que a tela manda. Confirmado por execução: 403, 403, e 201 só comrole: "", que nenhuma tela usa. - O filtro Todos/Ativo/Inativo não filtra. A tela manda
is_active, a rota não declara o parâmetro. Medido: 18 usuários nas três opções, com o mesmo inativo dentro das três.
Decisão do usuário (2026-08-20): os dois entram nesta spec — sem o primeiro, a jornada de cadastrar pelo celular leva a um botão que sempre falha; sem o segundo, "está ativo?" não tem resposta em viewport nenhum. E excluir usuário também passa a gravar auditoria, junto com ativar e desativar: é a remoção total do acesso, e sem ela o buraco continuaria aberto pelo caminho mais grave.
A rodada corrigiu ainda a extensão indevida do veredicto "funciona" da tela de criação para a de edição (as duas foram medidas, e as duas cabem), e a busca — que era resultado esperado sem ser custo declarado — virou as duas coisas.
A rodada 5 achou dois contornos e uma capacidade que não existe, e as três mudaram o escopo:
- O 403 do cadastro tem volta dentro do produto — criar um perfil personalizado e escolhê-lo passa (201, verificado). A spec dizia que "não há o que fazer a respeito"; há, ao custo de montar permissões à mão e ficar com um perfil gêmeo no tenant.
- Excluir usuário não existe em tela nenhuma —
usersApitemlist,get,createeupdate, e mais nada. Duas afirmações caíram junto: "apagar e recriar" nunca foi conserto disponível para o e-mail errado, e excluir não era, como o texto sugeria, uma ação existente fora do alcance no celular. Decisão do usuário (2026-08-20): a tela ganha a ação, ao lado de Desativar. - Perfis de Acesso entrava sem consequência escrita. Ganhou a dela — consultar "o que este perfil dá?" é a pergunta que antecede conceder um perfil pela Equipe —, e ganhou fronteira: decisão do usuário, o formulário de perfil não vira mobile; da tela entram a lista e a correção do checkbox, que é defeito de concessão de acesso e não de viewport.
A rodada 6 achou o que talvez seja o maior problema da tela, e ele não é de viewport: a lista da Equipe traz também os usuários de imobiliária. O convite de parceira cria User com o tenant_id da garantidora e um partner_agency_id, e list_by_tenant não separa. Medido: 10 dos 17. A consequência não é estética — é a ambiguidade do "Inativo", que pode ser acesso revogado ou convite pendente, exatamente sobre a pergunta que a spec promete responder. Decisão do usuário (2026-08-20): a Equipe passa a mostrar só quem é da garantidora.
Na mesma rodada: excluir e desativar fazem quase a mesma coisa — o DELETE é soft delete e nenhum dos dois libera o e-mail, cujo índice é único. Decisão do usuário: a diferença é de lista — desativar mantém a pessoa visível e marcada, excluir a tira de vista —, e a tela precisa dizer isso nos dois botões. E os resultados esperados foram alinhados à fronteira do formulário de perfil, que a rodada anterior tinha fechado e que o item 6 ainda contrariava.
As demais foram medidas em vez de discutidas: as coordenadas reais de cada coluna em 375px entraram na seção 3 (uma coluna inteira sobrevive, Perfis aparece por 14px, os botões de /users estão em 743→853 e os de /roles em 381→411 — todos fora da tela), o detalhe de perfil de sistema não tem Editar, o giro do aparelho recupera Perfis mas não Ações, e três atribuições erradas caíram (spec 0035 para a leitura de perfil de sistema, tenant-invite como convite de equipe, roles:list como exclusiva do ADMIN_TENANT).
Resultado: aprovada na 7ª rodada — 0 Critical, 0 Important, 5 Minor.
O laço rodou sete vezes, e não é o caso da spec 0036. Lá ele ficou preso no mesmo ponto, e o que travava eram decisões de negócio. Aqui nenhuma rodada repetiu achado: cada uma achou uma coisa nova e verdadeira sobre o produto, e quatro delas mudaram o escopo. O sinal não é de spec mal escrita — é de uma área que estava mais quebrada do que o pedido supunha. O pedido era "adequar a tela da equipe no mobile"; o que apareceu no caminho foi que o gestor não consegue cadastrar ninguém, que o filtro de situação nunca filtrou, que tirar acesso não deixa rastro, e que a lista traz mais gente de imobiliária do que da equipe.
O que virou método, e vale para as próximas: as três primeiras rodadas discutiram texto; a partir da quarta o revisor passou a executar contra o ambiente, e foi aí que os defeitos apareceram. Medir layout já era hábito (as coordenadas de cada coluna estão na seção 3); medir comportamento não era.
Pendências aceitas:
- A dor da busca encolhe com a separação dos dois públicos — de 17 pessoas para 7. A busca continua no escopo, e a razão principal passa a ser a paridade: é a única lista de negócio do produto sem ela.
- O 500 do recadastro com e-mail de usuário excluído está anotado no fora de escopo como material da Parte III — esta spec é a que torna o caminho alcançável.
- A visão do
ADMIN_GLOBALsobre/users(que não filtra tenant) precisa de uma decisão na Parte III sobre o corte porpartner_agency_id. Não é da Parte I.
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 III — Detalhamento¶
Não preencher antes das revisões das seções 6 e 8 estarem aprovadas.
9. Regras de negócio¶
O cadastro que não funciona¶
RN1 — Conceder um perfil não é alterar um perfil. POST /users recusa hoje qualquer perfil de sistema para quem não é ADMIN_GLOBAL, com a mensagem "Perfis do sistema não podem ser alterados". A guarda está protegendo a coisa errada: o que não se pode é editar a definição de um perfil de sistema (isso é da update_role, e continua valendo); atribuí-lo a alguém é a operação normal de administrar uma equipe, e é a mesma que POST /roles/{id}/users/{id} já permite ao ADMIN_TENANT. A guarda sai do create.
RN2 — ADMIN_GLOBAL não é concedível dentro de uma garantidora, e a regra passa a valer nos dois caminhos. Hoje ela existe só na listagem (role_repo.list_for_tenant esconde o perfil de quem não é administrador da plataforma, e o comentário no código diz o motivo: "not assignable within a tenant"); assign_role_to_user não a aplica, porque _check_role_access só recusa perfil de outro tenant e o ADMIN_GLOBAL tem tenant_id nulo. Quem souber o UUID do perfil concede hoje. A regra vai para o serviço e vale em POST /users e em POST /roles/{role_id}/users/{user_id}: quem não é ADMIN_GLOBAL não concede ADMIN_GLOBAL, por nome nem por id, e recebe 403 ROLE_NOT_ASSIGNABLE.
Sem a RN2, a RN1 seria uma escalada de privilégio. Tirar a guarda do
createsem pôr esta no lugar tornaria oADMIN_GLOBALconcedível pelo nome, num corpo de requisição, sem UUID nenhum. As duas regras andam juntas e nesta ordem.
RN2d — O nome de um perfil não concede poder. Hoje concede, e isso está no ar.
is_admin_global é "ADMIN_GLOBAL" in self._role_names, e _role_names é o conjunto dos nomes dos perfis do usuário, montado sem olhar is_system. O índice único de perfil de sistema é parcial (tenant_id IS NULL), então um perfil do tenant com esse nome não colide com nada, e o validador de nome exige apenas ^[A-Z0-9_]+$. Não há lista de nomes reservados em lugar nenhum.
Medido contra o ambiente de desenvolvimento: um ADMIN_TENANT cria um perfil chamado ADMIN_GLOBAL, com zero permissões — a API devolve 201, e ele aparece na lista de perfis da própria garantidora. Atribuído a um usuário, no próximo login essa conta passa por is_admin_global e por require_system_role, deixa de ser filtrada por tenant em _get_authorized, em list e em _check_role_access, e alcança todas as garantidoras. Quatro chamadas de API, nenhuma permissão envolvida. A variante barata é um perfil chamado ADMIN_TENANT, que passa nas oito rotas gateadas por papel de sistema sem carregar permissão alguma — e fura a RN16a, que confia no papel como porteiro de tela.
A ironia mede o tamanho do defeito: a docstring do
require_system_rolediz que ele existe "para operações que não devem ser concedíveis por perfil customizado (previne escalada de privilégio por permissões de perfil customizado)". Ele é derrotado pelo nome, não pelas permissões.
São duas camadas, e a primeira é a que conserta:
- Papel de sistema passa a ser reconhecido por
is_system, não por nome solto. Oget_current_usermonta um conjunto próprio — só os perfis comis_system=True—, eis_admin_global,is_admin_tenanterequire_system_rolepassam a ler dele. O_role_namescontinua existindo para o que ele de fato é: o que se mostra na tela. - Nome de papel de sistema é reservado em
POST /rolesePATCH /roles/{id}, com409 ROLE_NAME_RESERVED. Não é redundância: sem ela, dois perfis com o mesmo nome convivem na mesma lista, e ninguém que olha a tela consegue dizer qual é qual.
Antes de subir, conferir se algum tenant já tem perfil com nome de papel de sistema. Hoje não há cliente em produção, então a resposta esperada é nenhum. Se houver, a camada 1 já o torna inofensivo — quem o tem deixa de passar por is_admin_global e por require_system_role —, e o que resta é renomeá-lo à mão antes do deploy, para a tela não mostrar dois perfis com o mesmo nome. A consulta existe para saber se esse passo é necessário, não para decidir se sobe.
Este é o único defeito da spec que já está em produção, e é por isso que ele encabeça o plano (seção 14). Os outros dois — o 403 do cadastro e o filtro que não filtra — impedem trabalho; este dá a chave da plataforma a qualquer cliente.
RN2e — Conceder um perfil de sistema é ato de papel de sistema. A contenção mede permissões, e o poder de um papel de sistema não é feito só de permissões: são também as rotas gateadas por require_system_role — oito hoje, nove com a RN2b. As cinco de perfil — criar, editar, excluir, atribuir e revogar (GET /roles/{id} não é uma delas: exige roles:read) —, mais GET /permissions, PATCH /tenant-settings e GET /documents/storage-summary; e agora convidar imobiliária. Some-se PATCH /tenants/{id}, que confere papel dentro do serviço em vez de no porteiro, e o efeito é o mesmo.
Sem esta regra, a contenção tem um degrau que ela não enxerga. O gestor monta um perfil personalizado "Gestão" com exatamente as permissões que ele tem — a RN2c autoriza, é igualdade, e a Parte I registra que criar um perfil gêmeo é justamente o contorno que as pessoas usam hoje. Quem recebe "Gestão" passa na contenção ao conceder ADMIN_TENANT pelo cadastro, e a conta nova nasce com papel de sistema: atribui perfil, edita perfil e convida imobiliária — o degrau que a RN2b acabou de fechar um passo antes.
Então: role com is_system=True em POST /users e em POST /onboarding/users exige papel de sistema (ADMIN_GLOBAL ou ADMIN_TENANT), com 403 ROLE_NOT_ASSIGNABLE (decisão do usuário, 2026-08-21). Perfil personalizado continua passando pela contenção, que é o instrumento certo para ele.
É o quarto ato a receber a mesma forma, e com ele a lista fecha: atribuir perfil, revogar perfil, convidar imobiliária e conceder perfil de sistema exigem papel de sistema; conceder perfil personalizado passa pela contenção. A pergunta que separa os dois instrumentos é sempre a mesma — o que está sendo concedido é feito de permissões, ou de mais que isso?
RN2a — Ninguém concede um perfil que dá mais do que ele próprio tem. É a generalização da RN2, e ela existe porque a RN1, sozinha, abriria uma escalada mais barata que o ADMIN_GLOBAL: um perfil personalizado "Recepção", com users:list, users:read e users:create e mais nada, criaria um usuário com o perfil ADMIN_TENANT, definiria a senha na hora e entraria como administrador da garantidora. A regra: conceder um perfil exige que o conjunto de permissões dele esteja contido no de quem concede. Recusa: 403 ROLE_NOT_ASSIGNABLE, com a mensagem "Este perfil dá acessos que a sua conta não tem, e por isso você não pode concedê-lo."
A contenção vale onde há perfil escolhido, e são três caminhos:
| Caminho | Porteiro | Hoje | Depois |
|---|---|---|---|
POST /users | users:create | 403 para todo perfil de sistema (RN1) | contenção |
POST /roles/{id}/users/{id} | papel de sistema ADMIN_GLOBAL ou ADMIN_TENANT | qualquer perfil de sistema, inclusive ADMIN_GLOBAL | contenção, mais RN2 e RN3 |
POST /onboarding/users (convite de membro) | users:create | InvitableRole fecha ADMIN_GLOBAL e deixa ADMIN_TENANT aberto | contenção — e, na prática, papel de sistema: depois da RN3 o enum fica só com ADMIN_TENANT, ANALYST, OPERATOR e VIEWER, os quatro is_system=True, então a RN2e alcança a rota inteira |
No segundo, a contenção quase não morde, e é bom saber por quê. O porteiro dele é de papel de sistema, não de permissão: um perfil personalizado nunca chega ao serviço — ele recebe o 403 genérico do porteiro, e não o
ROLE_NOT_ASSIGNABLE. Quem chega ali éADMIN_TENANTouADMIN_GLOBAL. A contenção fica como rede, e quem faz o trabalho ali são a RN2 e a RN3.No terceiro, ela é o conserto de verdade. A 0035 fechou o
ADMIN_GLOBALno convite e chamou a lista de fechada;ADMIN_TENANTficou dentro dela, com porteirousers:create. Não foi descuido — foi o caso que ninguém tinha, porque perfil personalizado comusers:createe sem o resto é coisa que só aparece quando alguém monta um.E o caminho fundador fica de fora, pelo mesmo princípio da RN2b. A criação da garantidora atribui
ADMIN_TENANTa quem acabou de se registrar — que não tem permissão nenhuma, porque ainda não é nada. Contenção literal ali quebraria o cadastro de garantidora nova. O papel é fixo, não é escolha, e quem o concede é o sistema: não há de quem conter.
RN2b — O convite de imobiliária não é concessão de perfil, e o porteiro dele estava errado. Há um quarto caminho — POST /onboarding/partner-agencies/{id}/invite —, e ele é de outra natureza: o papel não é escolhido, é fixo (AGENCY_USER), e vem da parceria. A contenção não se aplica ali, e aplicá-la quebraria o caminho legítimo: medido no seed, AGENCY_USER − ADMIN_TENANT = {credit_analyses:withdraw} — o parceiro pode retirar a própria análise e o gestor não pode —, então nenhum ADMIN_TENANT conseguiria mais convidar uma imobiliária.
O que estava errado ali é o porteiro: ele exige users:create, e convidar imobiliária não é criar usuário da equipe. Passa a exigir papel de sistema — ADMIN_TENANT ou ADMIN_GLOBAL (decisão do usuário, 2026-08-20), que é como o produto já trata atribuir e revogar perfil.
Por que papel de sistema e não partner_agencies:update, que era a primeira leitura: porque ela é delegável. Está contida no ADMIN_TENANT, então a RN2c continua oferecendo a caixa, e a mesma escalada reabriria um passo adiante — o gestor monta o perfil "Parcerias", dá a alguém, essa pessoa convida uma imobiliária com um segundo e-mail que controla, aceita, e passa a operar com renters:*, properties:* e credit_analyses:*, inclusive credit_analyses:withdraw, que nem ela nem o próprio gestor conseguem conceder por caminho nenhum. Seria o argumento que a RN2a rejeita — "está no seed, logo é seguro" —, e o seed não é o limite: o limite é o que se pode delegar.
Todo ato que cria acesso passa a ter a mesma forma: atribuir perfil, revogar perfil e convidar imobiliária exigem papel de sistema; conceder perfil escolhido passa pela contenção. O caminho legítimo não muda — é o gestor quem convida imobiliária hoje.
Tirar
AGENCY_USERdoInvitableRolenão quebra o convite de parceria, embora à primeira leitura pareça: oinvite_agency_usergrava a string literal"AGENCY_USER"no código de verificação e não passa pelo enum, que é do convite de membro.E a contenção do convite é verificada na emissão, não no aceite — o papel fica congelado no convite. Se quem convidou perder a permissão no intervalo, nada reconfere. É estreito e é de propósito: reconferir no aceite faria um convite legítimo morrer por uma mudança de perfil que nada tem a ver com o convidado.
É o mesmo princípio da RN2a por outro instrumento: ali a pergunta é "você tem o que está concedendo?"; aqui é "você administra a coisa de onde esse acesso vem?". Contenção não serve para papel fixo, e porteiro não serve para perfil escolhido.
O ADMIN_GLOBAL fica de fora da contenção, e não é cortesia — é aritmética: medido no seed, TODAS − ADMIN_GLOBAL = {guarantees:dossier_attach, guarantees:dossier_amend}, porque a spec 0040 as subtraiu dele de propósito. Como as duas estão em ADMIN_TENANT e em OPERATOR, temos ADMIN_TENANT ⊄ ADMIN_GLOBAL: a contenção literal o impediria de conceder ADMIN_TENANT a alguém, e um cliente novo nasceria sem administrador.
E a suíte não acusaria. O comentário do próprio
rbac_seed.pyregistra que a fixture de teste concede todas as permissões aoADMIN_GLOBALsem consultar o mapa — o teste passaria verde e o ambiente com seed falharia. É a mesma armadilha que a RN4a da spec 0040 nomeou, e é por isso que a isenção é escrita aqui e não deduzida da RN2c.
A RN2 continua existindo à parte, e não é caso particular desta: o ADMIN_GLOBAL é inconcedível mesmo por quem tem todas as permissões do tenant, porque a fronteira ali é de plataforma e não de conjunto. Um ADMIN_TENANT com todas as permissões da garantidora passaria na contenção; a RN2 é o que ainda o barra.
RN2c — Ninguém monta um perfil que dá mais do que ele tem. A contenção da RN2a resolve conceder; sem esta, montar fica livre e produz um beco: medido no seed, TODAS − ADMIN_TENANT = {credit_analyses:withdraw, tenants:list}, e credit_analyses:withdraw é oferecida na tela de Perfis a quem não a tem. Uma caixa marcada bastaria para o gestor criar um perfil que ninguém naquela garantidora consegue atribuir — nem ele —, e a mensagem que ele leria ao tentar seria "Este perfil dá acessos que a sua conta não tem" sobre um perfil que ele acabou de montar.
As rotas de criar e editar perfil passam a aceitar só as permissões de quem chama (decisão do usuário, 2026-08-20), recusando com 403 PERMISSION_NOT_GRANTABLE. E o GET /permissions não filtra: marca — cada permissão vem com grantable: bool.
A diferença entre filtrar e marcar é o que impede um beco. Se a permissão fora do alcance sumisse da lista, o formulário de um perfil legado que já a carrega deixaria de desenhar aquela caixa: quem edita não conseguiria nem vê-la nem tirá-la, e o perfil ficaria inatribuível para sempre (a RN2a recusa concedê-lo) e inconsertável (a caixa não existe). Marcando, a caixa é desenhada, vem marcada, e não pode ser marcada — mas pode ser desmarcada. Acrescentar o que não se tem continua barrado; tirar nunca foi escalada, e é o que devolve o perfil ao alcance de todo mundo. É a mesma regra da RN2a aplicada um passo antes, e ela torna o beco inalcançável em vez de explicável.
O ADMIN_GLOBAL fica de fora da contenção, e não é cortesia: medido no seed, TODAS − ADMIN_GLOBAL = {guarantees:dossier_attach, guarantees:dossier_amend} — a spec 0040 as subtraiu dele de propósito. Aplicar a RN2c ao pé da letra sumiria com essas duas do seletor dele e as tornaria não-montáveis por ninguém, que é o mesmo beco do outro lado da fronteira. O desenho de hoje já tem essa forma (if not current_user.is_admin_global em list_permissions e em _sync_permissions) e é mantido.
Id de permissão que não existe é erro, não silêncio. Hoje _sync_permissions ignora id desconhecido. Com a RN2c introduzindo recusa dura por permissão não-concedível, ignorar um id inválido no meio de uma lista que está sendo julgada é o pior dos dois mundos: 422.
E a contenção mede o que está sendo acrescentado, não o que já estava lá. O PATCH recusa quando o conjunto novo traz uma permissão não-grantable que o antigo não tinha; manter uma que já estava lá não é recusado, e tirá-la é permitido — diminuir nunca foi escalada. Com a caixa desenhada e marcada (acima), a leitura é direta:
| O que o corpo traz | O que acontece |
|---|---|
permission_ids ausente | nada muda no conjunto — é o caso de salvar só o nome ou a descrição |
| a lista com a permissão fora do alcance | ela continua; não é recusa |
| a lista sem ela | ela é removida; foi desmarcada de propósito |
| a lista com uma fora do alcance que não estava lá | 403 PERMISSION_NOT_GRANTABLE |
A terceira linha é a que faz o beco não existir, e ela só funciona porque a segunda existe: com o GET /permissions marcando em vez de filtrando, a caixa é desenhada, então "veio no corpo" quer dizer "continua" e "não veio" quer dizer "desmarquei". Se a lista fosse filtrada, os dois seriam a mesma coisa, e a escolha entre preservar e remover cairia sobre quem implementa — preservando, ninguém consegue mais tirar a permissão; removendo, o perfil a perde em silêncio no primeiro salvamento de nome.
E a contenção substitui a lista de recursos não-delegáveis (decisão do usuário, 2026-08-20). Hoje _GLOBAL_ONLY_RESOURCES exclui o recurso tenants inteiro do que se pode pôr num perfil personalizado. A RN2c a torna redundante — e melhor:
| Permissão | Gestor tem? | Lista de recursos | Contenção |
|---|---|---|---|
tenants:list (a rota da plataforma; para quem não é ADMIN_GLOBAL ela já devolve só o próprio tenant) | não | fora | fora — ele não tem, não delega |
tenants:read, tenants:update (a própria garantidora) | sim | fora | dentro |
As duas últimas são o ponto: ler e editar os dados da própria garantidora não é administração de plataforma, e barrá-las era efeito colateral de proibir por recurso em vez de por conjunto. Um perfil "Financeiro" que precise ler os dados da própria empresa passa a ser montável, e nada que seja de plataforma atravessa — porque o gestor não tem nada de plataforma para delegar.
O ganho é menor do que parece, e vale saber antes de codar:
PATCH /tenants/{id}exige papel de sistema além da permissão, então um perfil personalizado comtenants:updatenão passa a editar a garantidora por ali. O caminho que a permissão sozinha abre éPATCH /onboarding/tenant. A RN2c não muda isso — só para de barrar por recurso o que a contenção já resolve por conjunto.É a mesma troca que a RN2 fez em outra escala: tirar a regra do lugar onde ela era conveniência (uma lista escrita à mão) e pô-la onde ela é regra (o conjunto de quem age).
RN3 — AGENCY_USER não se concede por perfil escolhido, e a regra não tem exceção — nem para o ADMIN_GLOBAL. Não é fronteira de autorização, é coerência: o papel do parceiro só faz sentido com um partner_agency_id, e nenhum dos três caminhos de perfil escolhido atribui vínculo — o UserCreate nem aceita o campo. Concedê-lo por ali produz uma pessoa com o conjunto de permissões do canal e sem canal nenhum, e isso é igualmente quebrado venha de quem vier. O administrador da plataforma continua criando usuário de imobiliária pelo caminho que existe para isso: o convite de parceria, que atribui o vínculo.
O seletor de perfil da Equipe oferece os perfis do tenant mais ADMIN_TENANT, ANALYST, OPERATOR e VIEWER. A API também recusa (403 ROLE_NOT_ASSIGNABLE): a tela é conveniência, a regra é do serviço — e a regra vale nos três caminhos de perfil escolhido. O segundo (POST /roles/{id}/users/{id}) é por onde ele é concedível hoje, porque list_for_tenant esconde só o ADMIN_GLOBAL, e é o que a tela de Editar usuário usa. O terceiro é o convite de membro (POST /onboarding/users), cujo InvitableRole lista AGENCY_USER explicitamente — e esse convite não atribui partner_agency_id, então ele produz exatamente o dano que esta RN descreve. AGENCY_USER sai do InvitableRole.
RN3a — Nome de perfil que não existe é erro, não silêncio. Hoje o serviço resolve o nome e, se não encontrar, ignora e devolve 201 com o usuário sem perfil. Enquanto o padrão era "OPERATOR" isso era borda; com a RN4 tornando "sem perfil" um resultado legítimo e comum, um nome errado passa a ser indistinguível de uma escolha deliberada, e quem chamou recebe 201 achando que concedeu. Passa a ser 422 ROLE_NOT_FOUND.
String vazia conta como ausência, e não como nome inexistente: role: "" é o mesmo que não mandar o campo, e cai na RN4. É o que o código já faz, e é a leitura que não quebra o único corpo que hoje passa (a volta descrita na seção 3).
RN4 — Perfil em branco cria alguém sem perfil nenhum. O campo role de UserCreate deixa de ter "OPERATOR" como padrão e passa a ser None. Quem não escolhe cria uma pessoa que entra e não alcança nada até receber um perfil pelo Editar. Conceder OPERATOR calado registraria na auditoria (RN8) uma concessão que ninguém fez.
A lista¶
RN5 — A Equipe lista só usuários da garantidora. GET /users passa a excluir quem tem partner_agency_id. Não é filtro opcional nem parâmetro: é o que a rota devolve. GET /users/{id} não muda — pedido por id, o usuário de imobiliária continua respondendo 200 dentro do tenant. A regra é sobre o que a lista da Equipe apresenta, não sobre quem pode ser lido: quem já alcançava o registro continua alcançando, e esconder por id só criaria uma diferença entre as duas rotas sem fechar nada. Quem administra usuário de imobiliária é a parceria, e essa tela não existe (seção 5).
RN6 — Busca, filtro de situação e paginação acontecem no servidor. GET /users ganha search (nome ou e-mail, parcial, sem diferenciar maiúsculas), is_active (que hoje é mandado pela tela e descartado pela rota) e devolve o envelope PaginatedResponse — items, total, page, page_size, pages —, como GET /partner-agencies já faz. Filtrar em memória sobre uma página filtra a página, não a lista: é o defeito que Imobiliárias e Imóveis já pagaram.
RN7 — A ordem é alfabética por nome, com id como desempate. Não é a ordem padrão do repositório (created_at desc), e a diferença é deliberada: numa lista de equipe se procura alguém pelo nome, e ordenar por cadastro moveria todo mundo de lugar a cada admissão. O desempate por id não é decoração — nomes repetidos existem, e OFFSET sem ordem total é loteria, que é a causa dos dois testes intermitentes que o CLAUDE.md registra.
A auditoria¶
RN8 — Toda mudança de acesso grava, por qualquer caminho. Sete eventos — cinco novos e dois que já existem —, e o entity_type segue a coisa alterada — usuário nos três primeiros, perfil nos três de concessão, que é como o evento existente já grava:
| Ação | Quando | Payload |
|---|---|---|
USER_DEACTIVATED (user) | PATCH com is_active: false | {"user_id", "name"} |
USER_REACTIVATED (user) | PATCH com is_active: true | idem |
USER_DELETED (user) | DELETE | idem |
ASSIGN_USER (role) | POST /roles/{id}/users/{id} | já existe — muda só a description (RN9) |
ASSIGN_USER (role) | perfil escolhido em POST /users | {"user_id", "role_name", "at_creation": true} — tenant: o do usuário criado, não o de quem chama |
REVOKE_USER (role) | DELETE /roles/{id}/users/{id} | já existe — muda a description (RN9) e o tenant_id |
ASSIGN_USER (role) | perfil concedido no aceite de convite — de membro e de imobiliária, que são a mesma função | {"user_id", "role_name", "from_invite": true} — ator: quem convidou; tenant: o do usuário convidado |
Os três primeiros levam o entity_id do usuário; os três ASSIGN_USER, o do perfil. Duas formas para a mesma concessão seriam o defeito da seção 1 outra vez: quem filtrasse por uma não veria a outra.
E é por isso que a tela de Auditoria ganha role na lista de tipos. Ela filtra por entidade a partir de uma lista fixa que tem user e não tem role — então hoje a concessão de perfil grava e não é filtrável. Sem essa linha, a spec entregaria metade do que promete: o registro existiria e não seria achável.
O tenant_id sai do usuário alvo, e não de quem executa — nos sete eventos, os cinco novos e os dois que já existem. O padrão de hoje (role.tenant_id or current_user.tenant_id) grava nulo em dois casos que se somam: perfil de sistema tem tenant_id nulo, e o ADMIN_GLOBAL também. Ou seja, o administrador da plataforma que atribui ou revoga ADMIN_TENANT num cliente grava a linha com tenant nulo, e ela some da Auditoria daquela garantidora — que é justamente quem precisava vê-la. É o cenário que a RN12 descreve como real, e sem esta regra ele fica invisível para quem foi afetado.
Um quinto caminho fica de fora, e é decisão: a criação da garantidora atribui ADMIN_TENANT ao fundador (onboarding_service), e isso não gera evento. É o ato fundador — não há garantidora antes dele, nem auditoria daquele tenant para receber a linha —, e o TenantInvite já registra quem convidou a garantidora. A RN8 vale para os quatro caminhos que concedem perfil a alguém dentro de uma garantidora que já existe.
O aceite de convite também grava, e é o terceiro caminho pelo qual um perfil é concedido hoje sem deixar rastro: accept_invite atribui o papel e não audita.
O ator é quem convidou, não quem aceitou (decisão do usuário, 2026-08-20). O accept_invite é rota pública e roda sem current_user; registrar o convidado faria a linha dizer que ele concedeu o perfil a si mesmo.
E esse dado não existe hoje — é a única migration desta spec. O VerificationCode guarda user_id (o convidado), code, type, expires_at, used_at e role_name, e nada sobre quem convidou; o TenantInvite, que é outro fluxo, tem invited_by_user_id, e o de usuário não. verification_codes ganha invited_by_user_id, FK para users, nullable — os convites já emitidos não têm de onde tirar o valor, e um NOT NULL os quebraria. O downgrade derruba a coluna. Um evento cujo convite antecede a migration grava com o ator nulo e a descrição dizendo "por convite", sem nome: é o que existe, e inventar um seria mentir sobre o passado.
O tenant_id do evento sai do usuário convidado (invited.tenant_id, gravado na emissão), e não do código de verificação, que também não o guarda. Importa porque a tela de Auditoria filtra por tenant: uma linha com tenant_id nulo some dela, e a CA29 falharia sem nada ter errado no código. Sem essa linha, a RN8 diria "por qualquer caminho" e cobriria dois de três — que é a queixa da seção 1 escrita como regra.
O evento é da mudança, não da requisição. Um PATCH só gera USER_DEACTIVATED ou USER_REACTIVATED quando is_active muda de valor — reenviar is_active: true de quem já está ativo não grava nada, e mudar só o nome também não. Sem essa leitura, um salvamento que reenvie o campo inalterado registraria uma reativação que não aconteceu, que é a queixa da seção 1 pelo avesso. E o evento do cadastro reusa a ação ASSIGN_USER de propósito: é a mesma concessão por outro caminho, e inventar uma ação nova faria quem filtra por ASSIGN_USER continuar sem ver metade delas.
RN9 — A descrição é o que se lê. A tela de Auditoria mostra a ação crua mais a description. As frases: "Usuário {nome} desativado", "Usuário {nome} reativado", "Usuário {nome} excluído", "Perfil '{perfil}' atribuído ao usuário {nome} na criação", "Perfil '{perfil}' concedido a {nome} por convite" e — no evento que já existe — "Perfil '{perfil}' revogado de {nome}".
E a descrição do evento que já existe passa a trazer o nome também. Hoje ela escreve o UUID do usuário ("Perfil 'X' atribuído ao usuário 3f2a…"), o que obriga quem lê a auditoria a ir procurar de quem é aquele id — numa lista da qual a pessoa pode já ter sido excluída. É uma linha, e é o que faz as quatro frases desta RN serem legíveis pelo mesmo critério.
RN9a — Quem lê os eventos novos é quem já lê a auditoria. A tela é gateada por audit:list, que no seed está em ADMIN_TENANT e ADMIN_GLOBAL — e essa permissão não muda. Então "quem desativou fulano, e quando" fica visível ao gestor e ao administrador da plataforma, e a mais ninguém. É o alcance de hoje da auditoria, e a spec não o alarga: o que faltava era o registro, não a leitura.
RN10 — Nenhuma ação pede justificativa. O registro guarda quem executou e quando, que é o que a Parte I pediu. Por que alguém decidiu não é campo que exista no produto, e esta spec não o cria.
Tirar acesso¶
RN11 — Desativar e excluir se distinguem por uma coisa: a lista. As duas cortam o acesso na hora. Desativar grava is_active=false e a pessoa continua listada, marcada. Excluir grava deleted_at e também is_active=false, e ela sai da lista. Nenhuma das duas apaga registro nem libera o e-mail, e nenhuma se desfaz pela tela — reativar existe para a primeira; para a segunda, não há caminho de volta na interface.
O
is_active=falsena exclusão não é redundância, é o que faz a ação valer. Hojesoft_deletegrava sódeleted_at, e as três portas de sessão —login,get_current_usererefresh— conferem apenasis_active. O excluído não consegue entrar de novo (oget_by_emaildo login o esconde), mas o token que ele já tem continua valendo, e o app o renova sozinho a cada expiração. Ou seja: hoje "Excluir", que a tela apresenta como a ação mais forte, corta menos que "Desativar", que ela apresenta como a mais branda — e o caso de sábado da seção 1, o do aparelho perdido, é exatamente onde isso custa caro.Alterar
is_activefecha as três portas de uma vez, sem tocar em nenhuma delas, e corrige a contagem da RN12 de quebra (ver ali).
RN11a — Aceitar convite não ressuscita quem foi excluído. Há uma quarta porta, e ela não confere is_active porque é ela quem o escreve: accept_invite carrega o usuário por user_id lido do código de verificação, com um get_by_id que não filtra deleted_at, grava is_active = True, define a senha e devolve o par de tokens. Ou seja, o aceite não passa por nenhuma das três guardas da RN11 — ele as antecede.
O caminho é natural, não rebuscado, e esta spec é quem o cria: não existe "cancelar convite" em tela nenhuma, então excluir a pessoa é o que alguém faz para desfazer um convite mandado por engano. O convidado nasce inativo com um código válido por 72h; excluído, abre o link, define a senha e entra — com o perfil que o convite carrega. "Excluir", que a RN11 promete cortar o acesso na hora, não cortaria justamente no caso em que o botão é usado.
accept_invite passa a recusar usuário com deleted_at, com 410 INVITE_INVALID e a mensagem "Este convite não é mais válido. Peça um novo a quem administra a garantidora."
O
410não tem classe emcore/exceptions.py— lá há 401, 403, 404, 409 e 422, e oBusinessRuleErroré 422 fixo. Ele entra comoAppErrorcomstatus_code=410, que a classe já aceita, e não se troca por um 422 reusado: "não existe mais" é o que 410 quer dizer, e é o que a pessoa precisa entender do link que ela tem na mão.
O discriminador é deleted_at, e só ele — is_active não serve. O convidado nasce inativo, e é o aceite que o ativa: recusar por is_active=false mataria todo convite do produto. E não há o caso intermediário que se poderia imaginar — "desativado que já tinha aceitado antes" —, porque os dois endpoints de convite recusam e-mail de usuário existente (e a RN14 estende isso aos excluídos), então quem já aceitou nunca tem um segundo código pendente. O used_at IS NULL que a consulta do aceite já aplica cuida do resto.
Recusar no aceite, e não invalidar os convites na exclusão, é o que cobre também os já emitidos e não pede varredura nenhuma.
RN12 — A garantidora não pode ficar sem quem a administre. Desativar, excluir ou revogar o perfil é recusado com 403 LAST_ADMIN quando o alvo é o último usuário ativo e não excluído com users:create do tenant do usuário alvo — não do de quem executa, que pode ser nulo quando é o ADMIN_GLOBAL agindo dentro de um cliente. E quem tem partner_agency_id não entra na conta, pelo mesmo raciocínio da RN5: se alguém do canal ganhasse users:create por perfil personalizado, a regra passaria a considerar a garantidora administrada por quem é de fora dela — e liberaria a saída do último gestor. O critério é o vínculo, não o papel. Na revogação a conta é sobre o efeito, não sobre o alvo. Recusa-se quando o tenant tem administrador antes da revogação e ficaria sem nenhum depois — e não quando o alvo por acaso é o último. As duas metades: sem a primeira, um tenant que já esteja sem administrador (alcançável pela "quarta porta" dos riscos aceitos) teria toda revogação recusada, inclusive as que nada têm a ver com administração, com uma mensagem absurda. A diferença é concreta: num tenant com um único administrador que tenha dois perfis, revogar o personalizado que não dá users:create não tira administração de ninguém, e a regra literal recusaria mesmo assim, com uma mensagem absurda. Nas outras duas portas a pergunta não existe, porque o efeito é total.
A revogação é o terceiro caminho, e é o que o auto-alvo não cobre: dentro do tenant, o último administrador não revoga a si mesmo porque assign/revoke já recusam o próprio usuário — mas o ADMIN_GLOBAL passa por essa guarda (_ensure_same_tenant o dispensa) e revogaria o perfil do último administrador de um cliente. Duas portas fechadas e a terceira aberta é o mesmo desenho que a RN2 veio corrigir.
Alvo sem tenant não entra na conta. Um usuário ADMIN_GLOBAL tem tenant_id nulo e não é administrador de garantidora nenhuma; desativá-lo ou excluí-lo não esvazia cliente nenhum, e a RN12 não se aplica. O que se aplica a ele é a RN13, como a todo mundo.
Quem executa, porém, passa pela guarda como qualquer um. O ADMIN_GLOBAL é a saída de quem se trancou fora (10.5), e isentá-lo da guarda seria permitir que ele esvaziasse a administração de um cliente sem aviso — a saída dele é conceder o perfil a outra pessoa antes, como qualquer um. As duas condições da contagem, e não só a primeira: com a RN11 gravando is_active=false na exclusão elas passam a coincidir, mas contar só por is_active deixaria a regra dependendo de uma consequência em vez de uma causa — e um excluído que ficasse ativo por qualquer caminho faria a conta liberar a saída do último administrador de verdade. A conta é feita sobre permissão efetiva, não sobre papel: um perfil personalizado com users:create conta igual. A mensagem diz a saída: "A garantidora ficaria sem ninguém para administrar acessos. Dê o perfil a outra pessoa antes." (regra nova, decisão do usuário, 2026-08-20.)
Sem ela, o caminho existe e é trivial: o gestor A exclui o gestor B, o gestor B exclui o A, e a garantidora depende do administrador da plataforma para voltar a ter alguém. A guarda de hoje cobre só o auto-alvo, que é o caso que ninguém tenta.
RN13 — Ninguém tira o próprio acesso. Já vale hoje para os dois — soft_delete e o PATCH com is_active: false recusam o próprio usuário. O que muda é a forma: as duas recusas passam a ter o código CANNOT_TARGET_SELF e a mesma mensagem, para a tela poder tratá-las igual. E a tela não oferece nenhuma das duas ações na própria linha.
RN14 — O e-mail de um usuário excluído continua ocupado, e o erro passa a dizer isso. Hoje get_by_email ignora excluídos e a coluna é única sem índice parcial: recadastrar com o mesmo endereço não dispara o ConflictError amigável e cai no tratador genérico — 500. Ninguém alcança isso hoje porque nenhuma tela exclui usuário; esta spec cria o caminho. A resposta passa a ser o 409 EMAIL_TAKEN que já existe.
E o conserto não é tirar o filtro do
get_by_email. Esse método é o que o login usa (AuthService.login), e o login confere senha eis_active— não conferedeleted_at. Tirar o filtro de lá devolveria o acesso a todo mundo que foi excluído: a pessoa desligada abriria o app na segunda-feira, digitaria a senha de sempre e entraria, com os perfis intactos. A verificação de duplicidade ganha consulta própria, que enxerga excluídos; oget_by_emaildo login não muda.E ela vale nos quatro pontos que inserem
User, e não só no cadastro:POST /users, o convite de membro, o convite de imobiliária e o cadastro público (POST /auth/register). Todos batem na mesma coluna única com a mesma checagem.O caminho é real e tem tela: exclui-se alguém pela Equipe — capacidade que esta spec cria — e depois convida-se uma imobiliária com aquele e-mail. E o público é o pior deles: a pessoa desligada abre o cadastro, digita o próprio endereço, e leva um 500 sem ter a quem perguntar. Há ainda um quinto ponto que só checa — o convite de garantidora —, e ele informa que o endereço está livre para depois falhar no aceite, que passa pelo
register. Ele usa a mesma consulta: o convite passa a ser recusado na emissão, com o409de sempre, em vez de nascer inutilizável. É o mesmo conserto, e não custa nada porque a consulta é a mesma.
As telas¶
RN14a — Perfil de outra garantidora responde 404, não 403. Hoje _check_role_access levanta ForbiddenError para perfil de outro tenant, enquanto perfil inexistente devolve 404 — e os dois códigos juntos dizem "existe, mas é de outra garantidora", que é o oráculo de enumeração que a RN4 da spec 0034 fechou em toda parte. A 0034 deixou role_id de fora por decisão registrada; a 0041 mexe neste serviço e nesta tela (o detalhe do perfil em 375px sai de GET /roles/{id}), e fechar custa uma linha. Vale em GET /roles/{id}, PATCH, DELETE e nas rotas de atribuir e revogar.
RN14b — Perfil em uso não se exclui. DELETE /roles/{id} passa a recusar com 409 ROLE_IN_USE enquanto houver usuário com aquele perfil, dizendo quantos são: "Este perfil está em uso por {n} pessoa(s). Remova-o delas antes de excluir."
Usuário excluído não conta. Excluir grava deleted_at e não desfaz o vínculo com o perfil (RN11), então contar todo mundo criaria um beco fechado: um perfil atribuído a uma pessoa só, que foi excluída, ficaria para sempre com 409 mandando removê-lo de alguém que não aparece em lista nenhuma — a Equipe lista só os não-excluídos. É a mesma condição da RN12, e pelo mesmo motivo. Hoje a rota devolve 204 e o ON DELETE CASCADE de user_roles tira o acesso de todo mundo que o tinha na mesma transação — sem uma linha de auditoria por pessoa, o que faria a RN8 ("toda mudança de acesso deixa registro, por qualquer caminho") ser falsa pelo caminho mais barato de todos. (Decisão do usuário, 2026-08-20.)
A Parte II já descrevia essa recusa (7.3, "Recusa ao excluir um perfil que está em uso") — ela existia na tela e não existia na API.
E isso fecha a "quarta porta" da RN12 pela metade que importa: apagar o perfil que carrega
users:createdeixa de ser um caminho para esvaziar a administração, porque não se apaga perfil que alguém está usando. O que sobra da quarta porta é sóPATCH /roles/{id}— editar o perfil tirando a permissão —, e essa continua registrada em Riscos aceitos.
RN15 — O quadro de permissões marca o que diz marcar. O FormControlLabel do formulário de perfil tem display: block, que empilha o checkbox acima do rótulo. Medido: idêntico em 1440px e em 375px, com 30px entre o quadrado e o texto que ele marca. É defeito de concessão de acesso, não de layout, e a correção vale nos dois viewports.
RN15a — O formulário de perfil mostra tudo, e bloqueia o que não se pode marcar. Cada caixa vem com o estado real do perfil; as que a pessoa não pode conceder vêm desabilitadas para marcar, com a explicação ao lado — "Você não tem este acesso, e por isso não pode concedê-lo." Se já vierem marcadas (perfil legado), continuam marcáveis para desmarcar. É o que faz a RN2c não criar beco, e é a diferença entre um formulário que esconde e um que explica.
RN15b — Quem entra sem perfil nenhum lê o que falta, e sai. É estado novo, criado pela RN4, e por isso precisa de regra e não só de descrição: a tela de recusa ganha uma variante para o usuário sem nenhum perfil — "Sua conta ainda não tem um perfil de acesso. Peça a um gestor da garantidora para atribuir um." —, e nela o único botão é Sair. Os dois de hoje ("Voltar" e "Ir ao painel") não servem: "Ir ao painel" navega para uma rota gateada que o devolve à mesma tela, em laço.
RN16 — Cada ação de tela fica atrás da permissão dela. users:update para Editar e Ativar/Desativar, users:delete para Excluir, users:create para "Novo usuário". Quem tem uma e não a outra recebe só a que tem. É o que a API já exige separadamente.
RN16a — Dois controles de tela ficam atrás de papel de sistema, e não de permissão. A RN16 liga cada ação à permissão dela, e há dois que a permissão não alcança:
- O painel "Perfis de acesso" do Editar usuário só aparece para
ADMIN_TENANTeADMIN_GLOBAL. As rotas de atribuir e revogar são gateadas por papel de sistema, então quem tem sóusers:update— que a RN16 considera suficiente para "Editar" — veria um painel que a API nega. - O seletor de perfil do cadastro some quando a pessoa não tem
roles:listou quando nenhum item vem comassignable: true. Um seletor vazio é o mesmo beco que um seletor negado.
RN17 — A visão da plataforma usa a MESMA rota, e é por isso que ela precisa de regra própria. /admin/users chama GET /api/v1/users, e o UserService.list resolve o ADMIN_GLOBAL num ramo do mesmo método — não há rota própria nem serviço próprio. Então a RN5 e a RN6 valem apenas no ramo do tenant, e o ramo do ADMIN_GLOBAL fica assim:
| Ramo do tenant | Ramo do ADMIN_GLOBAL | |
|---|---|---|
exclusão de partner_agency_id (RN5) | sim | não — ele administra a plataforma e precisa ver todo mundo |
search e is_active (RN6) | sim | sim, na rota — a tela de /admin não passa a usá-los |
envelope PaginatedResponse | sim | sim, com total cross-tenant |
| ordem alfabética (RN7) | sim | sim |
O que não vale para ele é só a RN5. Dizer "a RN5 é o que a rota devolve, ponto" seria sumir com os usuários de imobiliária da lista do administrador da plataforma — o oposto do que a CA20 exige.
E a tela de /admin/users não muda nesta spec. Ela carrega limit=200 e filtra em memória por garantidora, papel, situação e busca; passar a usar o recorte do servidor faria os filtros de garantidora e papel — que a rota não tem — recortarem a página em vez da lista, que é o defeito que a RN6 nomeia. Os parâmetros novos existem na rota e ela simplesmente não os manda. Adequá-la é outra spec, e ela está fora de escopo desde a Parte I.
10. Autorização e isolamento¶
10.1 Matriz de papéis¶
| Papel | Pode | Não pode |
|---|---|---|
ADMIN_GLOBAL | tudo o que já faz nas telas de /admin/*; ler a Equipe de qualquer garantidora, como já lê. Perfis, não: list_for_tenant devolve a ele só os de sistema — os personalizados de um cliente não aparecem em lista nenhuma, e nada nesta spec muda isso | conceder AGENCY_USER por perfil escolhido (RN3 não tem exceção — o vínculo não é atribuível por ali) · tirar o último administrador de um cliente, por desativar, excluir ou revogar (RN12) · distinguir perfil de outra garantidora de perfil inexistente — os dois viram 404 (RN14a). Conceder ADMIN_GLOBAL continua sendo dele, e a RN5 não se aplica à visão dele (RN17) |
ADMIN_TENANT | listar, criar, editar, ativar, desativar e excluir usuário do próprio tenant; conceder e revogar ADMIN_TENANT, ANALYST, OPERATOR, VIEWER e perfis personalizados do tenant — ele passa na contenção porque ANALYST, OPERATOR e VIEWER estão contidos nele | |
| (conferido no seed: os três deltas são vazios); ler e escrever perfis personalizados | conceder ADMIN_GLOBAL (RN2) · conceder AGENCY_USER (RN3) · mexer em usuário de outra garantidora · desativar/excluir a si mesmo (RN13) · tirar o último administrador (RN12) · editar perfil de sistema · montar perfil que dê mais do que ele tem (RN2c) | |
perfil personalizado com users:create (ex.: "Recepção") | criar usuário, e conceder só perfis personalizados contidos no dele (RN2a) | conceder qualquer perfil de sistema, mesmo tendo todas as permissões dele (RN2e) · conceder perfil personalizado que dê o que ele não tem (RN2a) · convidar imobiliária (RN2b: passa a exigir papel de sistema) |
ANALYST | ler a Equipe: nome, e-mail, perfil, situação; buscar e filtrar | criar, editar, ativar, desativar ou excluir usuário · abrir Perfis de Acesso (não tem roles:list) |
VIEWER | idem ANALYST | idem ANALYST |
OPERATOR | nada — não tem users:list, e o item não aparece no menu | abrir qualquer das duas telas |
AGENCY_USER | nada — idem | idem; e deixa de aparecer na lista da Equipe (RN5), o que não muda o acesso dele a coisa nenhuma |
10.2 Permissões¶
Nenhuma permissão nova. É decisão da Parte I e ela se sustenta: o que a spec conserta é recusa indevida e alcance de tela, não o alcance de papel. O rbac_seed.py não muda.
As exigidas por endpoint, todas já existentes:
| Endpoint | Exige |
|---|---|
GET /users | users:list |
GET /users/{id} | users:read |
POST /users | users:create |
PATCH /users/{id} | users:update |
DELETE /users/{id} | users:delete |
GET /roles, GET /roles/{id} | roles:list, roles:read |
POST /roles/{id}/users/{id} | papel de sistema ADMIN_GLOBAL ou ADMIN_TENANT — não muda, e é assim desde a 0034 |
DELETE /roles/{id}/users/{id} | idem — ganha LAST_ADMIN (RN12) e o 404 da RN14a |
POST /roles, PATCH /roles/{id}, DELETE /roles/{id} | idem — as duas primeiras ganham a RN2c, todas ganham o 404 da RN14a |
GET /permissions | idem — passa a marcar grantable em cada permissão, sem filtrar (RN2c) |
POST /onboarding/users | users:create — o porteiro não muda, mas depois da RN3 o enum só tem perfis de sistema, então a RN2e torna a rota alcançável só por papel de sistema |
POST /onboarding/partner-agencies/{id}/invite | muda: de users:create para papel de sistema ADMIN_GLOBAL/ADMIN_TENANT (RN2b) |
POST /auth/accept-invite | pública, e continua — é o convidado que chama, sem sessão. Passa a gravar auditoria (RN8), com o ator lido do convite, e a recusar usuário excluído (RN11a) |
POST /tenant-invites | require_admin_global — não muda; passa a recusar e-mail já usado na emissão (RN14) |
GET /auth/me | autenticado — ganha system_roles, subconjunto de roles, que já é devolvido ao próprio dono. Sem exposição nova |
POST /auth/register | pública, e continua — é a RN0a da spec 0035. Muda só a verificação de duplicidade (RN14) |
As recusas nomeadas desta spec são de regra, não de permissão — ROLE_NOT_ASSIGNABLE (RN2, RN2a e RN3), LAST_ADMIN (RN12), CANNOT_TARGET_SELF (RN13) e ROLE_NOT_FOUND (RN3a). Nenhuma delas se abre concedendo permissão a alguém, que é o ponto: são fronteiras do sistema, não níveis de acesso.
E uma delas — a RN2a — é a única que se resolve olhando permissão, e por isso vale dizer como: compara-se o conjunto de permissões do perfil a conceder com o de quem concede, e não os papéis. Comparar papéis produziria a mesma tabela de precedência que o RBAC granular substituiu, e ela não sabe o que um perfil personalizado dá.
10.3 Isolamento por garantidora (tenant)¶
GET /usersjá filtra portenant_idnolist_by_tenant; ganha a exclusão departner_agency_id(RN5). OADMIN_GLOBALpassa pela mesma rota — o que ele não percorre é o métodolist_by_tenant, e é por isso que a RN5 é regra de ramo e não de router (RN17).role_idvem no caminho emPOST /roles/{role_id}/users/{user_id}, e as duas pontas já são resolvidas no escopo:_check_role_accesspara o perfil e_ensure_same_tenantpara o usuário alvo — que devolve 404, não 403, para usuário de outra garantidora, pela RN4 da spec 0034. A RN2 entra depois dessas duas, e por isso não vira oráculo de existência: quem tenta concederADMIN_GLOBALa um usuário de outra garantidora recebe o 404 de sempre, e não o 403 novo.rolevem no corpo emPOST /users, por nome. A resolução é a que já existe —get_by_name(nome, tenant_id=None)e depois no tenant —, e a RN2, a RN2a e a RN3 correm sobre o perfil resolvido. Nome de perfil de outro tenant não resolve, porque a busca é no tenant de quem chama; nome nenhum resolve para422(RN3a), e não para 201 silencioso.- O convite por e-mail (
POST /onboarding/users) resolve o papel pelo enumInvitableRole, e a RN2a entra depois dele: a lista fechada continua barrandoADMIN_GLOBALno Pydantic, e a contenção passa a barrarADMIN_TENANTpara quem não o tem. - O usuário criado pela Equipe nasce sem
partner_agency_id, e o corpo não aceita esse campo — é o que fecha a RN5 pelo outro lado: a tela que lista só quem não tem vínculo também não cria ninguém com vínculo. O único caminho que atribui vínculo é o convite de imobiliária. - A contagem da RN12 é por tenant, roda sobre os usuários ativos e não excluídos daquele tenant, e desconsidera quem tem
partner_agency_id— é o mesmo critério da RN5, e pelo mesmo motivo: quem é do canal não administra a garantidora. Ela não enxerga nem conta ninguém de fora do tenant.
10.4 Isolamento por imobiliária¶
Não há escopo de imobiliária a aplicar. E o critério é o vínculo, não o papel — quem alcança a Equipe é quem tem users:list, que o papel AGENCY_USER do seed não tem, mas um perfil personalizado poderia dar a alguém com partner_agency_id. A RN5 é por partner_agency_id justamente por isso, e a frase precisa raciocinar do mesmo jeito. Hoje ninguém está nessa situação; a regra não depende disso.
A RN5 tira os usuários de parceira da lista. O que muda para eles é nada — nem acesso, nem permissão, nem capacidade. Eles deixam de ser listados numa tela que nunca foi deles.
O ponto de atenção é o inverso, e está na RN5: a exclusão é por partner_agency_id, não por papel. Filtrar por "não é AGENCY_USER" deixaria passar um usuário de parceira a quem alguém tenha concedido outro perfil, que é justamente o caso mais confuso.
10.5 Plataforma × cliente (SaaS admin)¶
A fronteira é a RN2, e ela é o coração desta seção. O ADMIN_GLOBAL administra a plataforma; uma garantidora não pode criar um administrador de plataforma. Hoje ela pode, se souber um UUID — e o conserto do cadastro (RN1) tornaria isso alcançável pelo nome. A regra sai da listagem, onde ela é conveniência de tela, e vai para o serviço, onde é regra.
O ADMIN_GLOBAL não ganha nada nesta spec. Ele não passa pelas telas de Equipe e Perfis no uso normal — tem as de /admin/* —, e a RN17 declara que a RN5 não vale lá.
E o que ele perde é pouco, mas não é nada — está na matriz da 10.1: deixa de conceder AGENCY_USER por perfil escolhido (RN3), passa a receber 403 LAST_ADMIN ao tirar o último administrador de um cliente (RN12), e as recusas de perfil alheio viram 404 para ele também (RN14a). Continua sendo o único que concede ADMIN_GLOBAL, e continua sendo a saída quando uma garantidora se tranca fora — que a RN12 passa a tornar improvável, não impossível.
11. Impacto por app¶
apps/api¶
schemas/user.py—UserCreate.rolepassa destrparastr | None, com padrãoNone(RN4) — a anotação muda junto do valor, senão o Pydantic recusa onullque a tela manda.UserListParamsnão existe; os filtros vão comoQuery.UserResponseganhasystem_roles: list[str]— os nomes dos perfis do usuário que têmis_system=True. É o que a camada 1 da RN2d exige do lado da tela:rolescontinua sendo tudo o que se mostra, e a decisão de "é papel de sistema?" passa a ter campo próprio.MeResponseherda.services/user_service.py— sai a guarda deis_systemdocreate(RN1); entram a RN2 e a RN3 na resolução do perfil; entra a auditoria do perfil concedido no cadastro (RN8);updateesoft_deletepassam a auditar e a aplicar a RN12 e a RN13; a busca de duplicidade de e-mail passa a enxergar excluídos (RN14).repositories/user_repo.py—list_by_tenantganhasearch,is_active, a exclusão porpartner_agency_id(RN5), a contagem para o envelope e a ordem alfabética (RN7);list(o ramo doADMIN_GLOBAL) ganha os mesmos, menos a exclusão porpartner_agency_id(RN17).get_by_emailnão muda — é o do login (RN14); entra umexiste_emailpróprio, que enxerga excluídos.services/permission_service.py— a RN2, a RN2a, a RN2e e a RN3 entram emassign_role_to_user, depois de_check_role_accesse_ensure_same_tenant(10.3). A comparação de conjuntos da RN2a mora aqui e é reusada pelos outros dois caminhos.services/onboarding_service.py— a RN2a e a RN3 no convite de membro; os dois convites passam a gravarinvited_by_user_idna emissão, que é a única origem do ator; a auditoria do aceite (RN8), com o ator lido do convite e o tenant do usuário convidado; a RN11a noaccept_invite; a RN14 noregistere nos dois convites.services/tenant_invite_service.py— a RN14 no convite de garantidora, que é onde ele mora (e não noonboarding_service): hoje ele confere o e-mail com o mesmo método e informa que o endereço está livre, para o aceite falhar depois.core/dependencies.pyemodels/user.py— o conjunto de papéis de sistema, separado do de nomes, eis_admin_global/is_admin_tenant/require_system_rolelendo dele (RN2d).services/permission_service.py, uma terceira coisa — nome de papel de sistema reservado emcreate_roleeupdate_role(RN2d).api/v1/routers/roles.pyeapi/v1/routers/permissions.py— Swagger das cinco rotas que ganham recusa nova ou trocam código de erro; os porteiros não mudam.schemas/role.py—RoleListResponseganhaassignable: bool(§12).models/verification_code.py—invited_by_user_id, FKuserscomondelete="SET NULL"(a irmãuser_idusaCASCADE, e a purga de tenant apaga usuário de verdade: comCASCADEo registro do convite sumiria junto de quem convidou), e a migration correspondente.api/v1/routers/onboarding.py— o porteiro do convite de imobiliária passa derequire_permission("users","create")pararequire_system_role("ADMIN_GLOBAL", "ADMIN_TENANT")(RN2b). É uma linha, e é o conserto inteiro daquele caminho.schemas/onboarding.py—AGENCY_USERsai doInvitableRole(RN3).services/permission_service.py, de novo — a RN14b (delete_roleconta portadores antes de apagar); a RN2c substituindo_GLOBAL_ONLY_RESOURCES:list_permissionspassa a marcargrantableem vez de filtrar, e_sync_permissionsrecusa o que não égrantablee não estava lá antes; e_check_role_accesspassa deForbiddenErrorparaNotFoundErrorem perfil de outro tenant (RN14a);list_permissions,create_roleeupdate_role(RN2c); a RN12 entra também emrevoke_role_from_user.schemas/user.py, de novo —UserResponse.system_roles, que oMeResponseherda (RN2d). EUserService._load_rolesmonta o conjunto junto — é ele que alimenta oUserResponsede quem não é o solicitante, então sem issosystem_rolesviria vazio emGET /users.schemas/permission.py—PermissionResponse.grantable: bool(RN2c).api/v1/routers/auth.py— oMeResponseé montado à mão ali (roles=list(...)), então sem tocar neste arquivo osystem_rolesnão sai no/auth/me, e o contrato da §12 e a CA6x ficam sem lastro.api/v1/routers/users.py—GETganhasearch,is_activee o envelopePaginatedResponse[UserResponse]; Swagger das cinco rotas com os códigos novos.-
Uma migration,
xxxx_invited_by_e_desativa_excluidos, com duas coisas dentro — e o nome diz as duas, porque odowngradedesfaz uma e não desfaz a outra: -
A coluna.
verification_codes.invited_by_user_id, FKusers,nullable, comondelete="SET NULL". É o que a decisão do ator do convite exige (RN8), e o dado não existe hoje.nullableporque os convites já emitidos não têm de onde tirar o valor. Odowngradea derruba. - O backfill.
UPDATE users SET is_active = false WHERE deleted_at IS NOT NULL. Quem foi excluído antes desta spec continua comis_active = true, e um token dele seguiria se renovando pelorefresh, que só confereis_active(RN11). Odowngradenão o desfaz: reativar quem foi excluído seria pior que deixar uma coluna a mais.
audit_logs.payload é JSON e action é String(50), então os eventos novos são valores e não schema. Nada mais muda.
apps/web¶
components/shared/RecordCard.tsx— a linha demetapassa a aceitar duas alturas, por opção. Quem não pedir continuanoWrap: Inquilinos, Imóveis e a lista de usuários do/adminnão mudam.pages/users/index.tsx— reescrita:FilterBar/MobileFilterBar,RecordCardem 375px,ListPagination, terceira ação na linha, linha clicável, permissão por ação, "sem perfil", fim da opacidade, os três diálogos.pages/users/api.ts—listcomsearch/is_active/envelope; ganharemove.pages/users/form.tsx— os dois seletores de perfil (o do cadastro e o "Adicionar perfil" do painel de edição) oferecem só os itens comassignable: true; o do cadastro ganha a frase do campo em branco. Quem não temroles:listnão recebe a lista (oGET /rolesresponde 403, e a tela já engole o erro): para essa pessoa o seletor não aparece, com a frase dizendo que o perfil será atribuído depois por quem administra perfis — em vez de um seletor vazio, que é o que acontece hoje. Vale igual quando a lista vem e nenhum item temassignable: true: um seletor com zero opções é o mesmo beco por outro caminho.- O painel "Perfis de acesso" do Editar usuário só aparece para quem é
ADMIN_TENANTouADMIN_GLOBAL: as rotas de atribuir e revogar são gateadas por papel de sistema, e quem tem sóusers:updateveria um painel que a API nega. É o mesmo princípio da RN16, aplicado a um controle que a RN16 sozinha não alcança. E o seletor "Adicionar perfil" dentro dele filtra porassignable, como o do cadastro — é o outro consumidor da mesma lista, e hoje ele oferece tudo o queGET /rolesdevolve menos o que a pessoa já tem. Sem o filtro, oADMIN_TENANTcontinuaria vendoAGENCY_USERali e tomaria o 403 da RN3 ao clicar: uma recusa garantida, oferecida pela tela, num perfil que esta spec acabou de tornar inconcedível. pages/roles/index.tsx— cartões em 375px, dois blocos, e o clique na linha passa a valer nos dois tipos de perfil.pages/roles/show.tsx— bloco por recurso em 375px, colapsado, com contagem.pages/roles/form.tsx— odisplay: blockdoFormControlLabel(RN15), que é uma linha, mais a RN15a, que não é: caixa desabilitada para o que não égrantable, a frase explicativa ao lado, e a permissão legada vindo marcada e desmarcável.pages/unauthorized/index.tsx— a variante "sem perfil nenhum", com "Sair" no lugar dos dois botões.pages/agencies/show.tsx— "Convidar operador" hoje é ação primária sem porteiro nenhum. Com a RN2b trocando o porteiro da rota, quem tempartner_agencies:reade não tem papel de sistema passaria a clicar para tomar 403. É a única tela cujo porteiro esta spec muda, e ela segue a RN16a: o botão só aparece paraADMIN_TENANTeADMIN_GLOBAL.pages/audit-logs/index.tsx—roleentra na lista de tipos de entidade do filtro (RN8), sem o que a concessão de perfil grava e não é achável.shared/utils/roleUtils.ts,App.tsx,components/navigation/nav-groups.tsxeproviders/auth.ts— este último decide o redirecionamento para/adminporme.roles.includes("ADMIN_GLOBAL"): sem ele na lista, a camada 1 tira o poder no servidor e a pessoa continua sendo mandada para uma área onde toda chamada responde 403. Os portões de tela que leemme.rolespor nome (ExigeAcesso papel="ADMIN_TENANT",requerPapel) passam a ler o conjunto de papéis de sistema, que o/auth/mepassa a devolver separado (RN2d). Sem isso, um portador de perfil legado com nome de papel de sistema deixaria de passar na API e continuaria vendo o menu — 403 em vez de item escondido. É resíduo estreito, porque a camada 2 e a consulta pré-deploy o tornam quase teórico, e é barato fechar junto.data-testidem tudo que é novo: cartões, ações do rodapé (documents-style, um por ação), pílula de situação, busca, blocos colapsáveis do detalhe de perfil e os três diálogos de confirmação — Ativar, Desativar e Excluir —, que são o que a CA28 verifica pelo texto.
apps/e2e¶
- Spec nova cobrindo: o cadastro que passa a funcionar (com perfil de sistema e sem perfil), a recusa de
ADMIN_GLOBALe deAGENCY_USER, o filtro que filtra, a busca, a ausência dos usuários de imobiliária, desativar e excluir com os textos certos, a recusa do último administrador, e 375px nas três telas — sem rolagem horizontal, com as ações ao alcance. - O
ANALYSTabrindo a Equipe e não recebendo ação nenhuma — é o negativo de tela que só o e2e pega. - Sem seed novo: o mundo é montado pela API, como nas specs 0038, 0039 e 0040.
12. Contrato de API¶
GET /api/v1/users Permissão: users:list
Query: skip=0 · limit=50 (máx. 200) · search · is_active
200 { "items": [UserResponse], "total": 7, "page": 1, "page_size": 50, "pages": 1 }
searchcasa nome ou e-mail, parcial, sem diferenciar maiúsculas.is_activefiltra de verdade (hoje é aceito pela tela e descartado pela rota).- A resposta nunca traz quem tem
partner_agency_id(RN5). - Ordem alfabética por
name,idcomo desempate (RN7). - Quebra de contrato: a rota devolvia uma lista crua. Os dois clientes são do
apps/web(a Equipe e a visão de/admin), versionados junto — é o caso que oCLAUDE.mdchama de barato. Os dois já toleram o envelope:toArrayempages/users/api.tse odata.items ?? datadoadminApi. Nenhum deles quebra ao receber o formato novo, o que torna a ordem do plano menos crítica do que parece — a razão de fazer a API primeiro é outra (ver seção 14).
POST /api/v1/users Permissão: users:create
{ "name", "email", "password", "role": null, "tenant_id": null }
↑ `role` opcional, sem padrão (RN4)
↑ só o ADMIN_GLOBAL escolhe; para os demais,
`null` ou o próprio — diferente disso é 403,
como já é hoje. **Não muda, e não fere a RN4 da
0034:** a comparação é entre dois valores do
corpo e da sessão, sem consultar o banco, então
o 403 não distingue tenant que existe de tenant
que não existe.
201 UserResponse
| Erro | Quando |
|---|---|
403 ROLE_NOT_ASSIGNABLE | role é ADMIN_GLOBAL e quem chama não é (RN2) · é AGENCY_USER, para qualquer chamador (RN3) · é de sistema e quem chama não tem papel de sistema (RN2e) · dá permissão que quem chama não tem, exceto ADMIN_GLOBAL (RN2a) |
422 ROLE_NOT_FOUND | role é um nome que não existe no tenant nem entre os de sistema (RN3a) |
409 EMAIL_TAKEN | e-mail já usado — inclusive por usuário excluído (RN14) |
PATCH /api/v1/users/{id} → 200 UserResponse Permissão: users:update
DELETE /api/v1/users/{id} → 204 sem corpo Permissão: users:delete
| Erro | Quando |
|---|---|
403 LAST_ADMIN | o alvo é o último usuário ativo com users:create do tenant (RN12) — é a última guarda: vem depois do 404 de escopo e do CANNOT_TARGET_SELF, porque depende de carregar o alvo e contar, e inverter a ordem faria a contagem virar oráculo de existência |
403 CANNOT_TARGET_SELF | o alvo é quem chama (RN13) — hoje só o DELETE recusa |
404 | usuário de outra garantidora |
| Erro | Quando |
|---|---|
403 ROLE_NOT_ASSIGNABLE | o perfil é ADMIN_GLOBAL e quem chama não é (RN2) · é AGENCY_USER (RN3) · dá o que quem chama não tem (RN2a) |
404 | usuário de outra garantidora — continua vindo antes do 403 novo (10.3) · perfil de outra garantidora, que hoje responde 403 (RN14a) |
Cada item ganha assignable: bool — se quem está chamando pode conceder aquele perfil, com todas as regras aplicadas: RN2 (ADMIN_GLOBAL), RN2a (contenção), RN2e (perfil de sistema só por papel de sistema) e RN3 (AGENCY_USER nunca). A RN2e não pode ficar de fora: sem ela, quem carrega um perfil gêmeo do ADMIN_TENANT receberia os quatro perfis de sistema com assignable: true, escolheria um, e tomaria o 403 da RN2e — a tela convidando ao que a API nega, que é o princípio que a 7.3 e a RN16a fixam. Sem isso a tela não tem como cumprir a RN2a: o RoleListResponse não traz as permissões do perfil, e computar contenção no cliente exigiria uma chamada por perfil. A regra é do servidor; o campo só diz o resultado dela, e a API recusa de novo se alguém ignorar o campo.
GET /roles não muda em mais nada, e não ganha paginação. O número de perfis é limitado por desenho — seis de sistema mais os personalizados que a garantidora criar —, e paginar uma lista que cabe numa tela é custo sem contrapartida.
Revogar perfil. O porteiro não muda; o que muda são duas recusas.
| Erro | Quando |
|---|---|
403 LAST_ADMIN | depois da revogação o tenant ficaria sem ninguém com users:create (RN12) |
404 | usuário ou perfil de outra garantidora (RN14a) |
GET /api/v1/roles/{id} Permissão: roles:read
POST /api/v1/roles Papel: ADMIN_GLOBAL ou ADMIN_TENANT
PATCH /api/v1/roles/{id}
DELETE /api/v1/roles/{id}
O GET é o que alimenta a tela de detalhe do perfil, que esta spec põe em 375px. Ele não muda, exceto pelo 404 da RN14a.
| Erro | Quando |
|---|---|
403 PERMISSION_NOT_GRANTABLE | o corpo pede permissão que quem chama não tem (RN2c) — só POST e PATCH |
409 ROLE_NAME_RESERVED | name é o de um papel de sistema (RN2d) — só POST e PATCH |
422 | permission_ids traz id que não existe (RN2c) — hoje é ignorado em silêncio |
409 ROLE_IN_USE | DELETE de perfil que alguém está usando (RN14b) |
404 | perfil de outra garantidora, que hoje responde 403 (RN14a) |
Devolve todas as permissões, cada uma com grantable: bool — se quem está chamando pode concedê-la (RN2c). Não filtra: filtrar recriaria o beco do perfil legado, cuja caixa deixaria de ser desenhada e portanto de poder ser desmarcada. Para o ADMIN_GLOBAL, todas vêm true.
Convite de garantidora. Nada muda além da verificação de duplicidade: passa a enxergar excluídos e a recusar com 409 EMAIL_TAKEN na emissão, em vez de deixar o convite nascer inutilizável (RN14).
UserResponse ganha system_roles — os nomes dos perfis com is_system=True, subconjunto de roles. Existe porque as três telas que decidem por papel (ExigeAcesso, requerPapel, roleUtils) hoje comparam nome, e depois da RN2d isso passa a divergir da API: quem tiver um perfil legado chamado ADMIN_TENANT deixaria de passar no servidor e continuaria vendo o menu.
Convite de membro da equipe. InvitableRole perde AGENCY_USER (RN3) e continua sem ADMIN_GLOBAL.
| Erro | Quando |
|---|---|
403 ROLE_NOT_ASSIGNABLE | o papel dá o que quem convida não tem (RN2a) · é de sistema e quem convida não tem papel de sistema (RN2e) |
422 | role fora do InvitableRole — o Pydantic recusa antes de qualquer escrita |
Convite de imobiliária. O porteiro muda — era users:create (RN2b). O papel concedido é fixo (AGENCY_USER) e não passa por contenção: ele vem da parceria, não é escolha de quem convida.
| Erro | Quando |
|---|---|
403 | quem chama não tem papel de sistema — fecha a "Recepção" e também o "Parcerias", que um perfil delegável poderia carregar |
POST /api/v1/auth/accept-invite Pública — é o convidado que chama
POST /api/v1/auth/register Pública — RN0a da spec 0035
| Erro | Quando |
|---|---|
410 INVITE_INVALID | o usuário do convite tem deleted_at (RN11a) — só no accept-invite. Não se confere is_active: o convidado nasce inativo, e conferir mataria todo convite |
409 EMAIL_TAKEN | e-mail já usado, inclusive por excluído (RN14) — só no register |
O accept-invite passa a gravar ASSIGN_USER com o ator lido do convite (RN8).
As mensagens, por extenso (é o que a pessoa lê, e a Parte II as usa):
| Código | Mensagem |
|---|---|
ROLE_NOT_ASSIGNABLE (global) | "Este perfil é do administrador da plataforma e não pode ser concedido dentro de uma garantidora." |
ROLE_NOT_ASSIGNABLE (sistema) | "Este perfil é do sistema. Só quem administra a garantidora pode concedê-lo." |
ROLE_NOT_ASSIGNABLE (contenção) | "Este perfil dá acessos que a sua conta não tem, e por isso você não pode concedê-lo." |
ROLE_NOT_ASSIGNABLE (agency) | "O perfil de imobiliária é concedido pelo convite de parceria, não pela Equipe." |
LAST_ADMIN | "A garantidora ficaria sem ninguém para administrar acessos. Dê o perfil a outra pessoa antes." |
CANNOT_TARGET_SELF | "Você não pode desativar ou excluir a sua própria conta." |
ROLE_IN_USE | "Este perfil está em uso por {n} pessoa(s). Remova-o delas antes de excluir." |
PERMISSION_NOT_GRANTABLE | "Este perfil pede acessos que a sua conta não tem, e por isso você não pode salvá-lo assim." — vale para criar e para editar |
ROLE_NOT_FOUND | "Este perfil não existe. Escolha um da lista ou deixe em branco." |
ROLE_NAME_RESERVED | "Este nome é de um perfil do sistema. Escolha outro." |
INVITE_INVALID | "Este convite não é mais válido. Peça um novo a quem administra a garantidora." |
EMAIL_TAKEN | "Este e-mail já está vinculado a outro usuário na plataforma. Utilize um e-mail diferente." — a mesma nos cinco pontos que a RN14 unifica — hoje são quatro textos diferentes, e um deles manda usar "Esqueci minha senha", conselho que não serve para conta excluída: a redefinição é silenciosa para quem tem deleted_at |
13. Critérios de aceite¶
[x]é entregue e provado;[~]é entregue e provado por um dos caminhos. Os dois[~]são de cobertura por caminho, e o comportamento é o mesmo objeto compartilhado nos dois: a RN2e é julgada peloservices/role_granting.py, que os três caminhos de concessão chamam, e a auditoria do aceite é o mesmo bloco deaccept_invitepara os dois tipos de convite. Estão registrados em Defeitos aceitos.
O cadastro
- [x] CA1. Um
ADMIN_TENANTcria usuário comrole: "OPERATOR"e recebe 201, com o perfil atribuído. Hoje é 403. - [x] CA2. Cria sem
rolee recebe 201, comroles: []— e a auditoria não registra concessão nenhuma (RN4). - [x] CA3. Cria com
role: "ADMIN_GLOBAL"e recebe403 ROLE_NOT_ASSIGNABLE(RN2), e nenhum usuário é gravado — com a RN14 tornando o e-mail permanentemente ocupado, um usuário órfão custaria o endereço para sempre. Vale igual para CA4, CA4a e CA4c. - [x] CA4. Cria com
role: "AGENCY_USER"e recebe403 ROLE_NOT_ASSIGNABLE(RN3), pelos dois caminhos — no cadastro e emPOST /roles/{id}/users/{id}, que é por onde ele é concedível hoje. - [x] CA4a. Um perfil personalizado com
users:list,users:readeusers:createe mais nada recebe403 ROLE_NOT_ASSIGNABLEao tentar criar alguém comADMIN_TENANT, emPOST /userse emPOST /onboarding/users(RN2a). É o negativo mais importante desta spec: sem ele, a RN1 é uma escalada de privilégio. - [x] CA4a1. O mesmo perfil recebe 403 do porteiro — o genérico, não o
ROLE_NOT_ASSIGNABLE— emPOST /roles/{id}/users/{id}, que é gateado por papel de sistema e nunca chega ao serviço. O critério existe para o teste ser escrito uma vez só, com o código certo. - [x] CA4d. Um
ADMIN_GLOBALconcedeADMIN_TENANTeOPERATORnormalmente — a contenção não se aplica a ele (RN2a). O teste não pode usar a fixture que concede todas as permissões, senão passa por engano: o sujeito é umADMIN_GLOBALcom o conjunto que o seed dá a ele. - [x] CA4b. O mesmo perfil consegue conceder um perfil personalizado contido no dele — a contenção recusa o que dá mais, e não recusa sempre.
- [~] CA4e. Um perfil personalizado gêmeo do
ADMIN_TENANT— com exatamente as mesmas permissões, portanto passando na contenção — recebe403 ROLE_NOT_ASSIGNABLEao concederADMIN_TENANT, emPOST /userse emPOST /onboarding/users(RN2e). É o negativo que prova que a contenção sozinha não bastava: sem ele, quem recebesse esse perfil promoveria alguém a papel de sistema e ganharia as nove rotas que a permissão não dá. - [x] CA4c.
role: "PERFIL_QUE_NAO_EXISTE"devolve422 ROLE_NOT_FOUND, e não 201 com o usuário sem perfil (RN3a). - [x] CA5. Um
ADMIN_GLOBALcontinua criando com qualquer perfil, inclusiveADMIN_GLOBAL— excetoAGENCY_USER, que a RN3 recusa para todo mundo, porque o vínculo de imobiliária não é atribuível por esse caminho. - [x] CA6.
POST /roles/{id}/users/{id}com o perfilADMIN_GLOBALe umADMIN_TENANTchamando devolve403 ROLE_NOT_ASSIGNABLE— é o buraco que existe hoje, e o teste é o que impede que ele volte. - [x] CA6x.
GET /auth/medevolvesystem_rolescom só os perfis de sistema — um usuário com o papelANALYSTe um perfil personalizado qualquer traz os dois emrolese só o primeiro emsystem_roles(RN2d). - [x] CA6y. O mesmo para um perfil chamado
ADMIN_TENANT: ele não abre as rotas gateadas por papel de sistema (RN2d). - [x] CA6w. Renomear um perfil personalizado para
ADMIN_TENANTrecebe o mesmo409 ROLE_NAME_RESERVED— a recusa vale emPATCH, e não só emPOST(RN2d). É o mesmo ataque pela outra porta. - [x] CA6z. Um
ADMIN_TENANTque cria um perfil chamadoADMIN_GLOBALrecebe409 ROLE_NAME_RESERVED— e mesmo que um perfil assim exista (criado antes desta spec), quem o recebe não passa poris_admin_globalnem porrequire_system_role, e continua filtrado por tenant emGET /users,_get_authorizede_check_role_access(RN2d). São dois critérios num só de propósito: a segunda metade é a que conserta, e a primeira só impede a confusão de dois perfis com o mesmo nome. - [x] CA6a. O mesmo, com um usuário de outra garantidora: devolve 404, não 403 — a ordem das guardas da 10.3 é verificável pelo código do erro.
A lista
- [x] CA7.
GET /users?is_active=falsedevolve só inativos;?is_active=true, só ativos; sem o parâmetro, os dois. Hoje as três devolvem tudo. - [x] CA8.
?search=casa nome e e-mail, parcial e sem diferenciar maiúsculas. - [x] CA9. Com token de tenant, nenhum usuário com
partner_agency_idaparece na resposta, em nenhuma combinação de filtros (RN5). Com token deADMIN_GLOBALé o contrário, e é a CA20. - [x] CA10. A resposta vem no envelope, e
totalconta a lista inteira, não a página. - [x] CA11. Duas páginas de 5 sobre 10 usuários não repetem nem perdem ninguém, e a ordem é alfabética (RN7).
Tirar acesso
- [x] CA12. Desativar grava
USER_DEACTIVATED; reativar gravaUSER_REACTIVATED; excluir gravaUSER_DELETED— todos comentity_type="user", o ator e o horário. - [x] CA12a. Excluir grava
is_active=falsejunto comdeleted_at(RN11), e o token que a pessoa já tinha deixa de funcionar: uma chamada autenticada com ele responde 401. É o critério que impede "Excluir" de cortar menos que "Desativar". - [x] CA12c. Um usuário excluído com convite pendente que abre o link recebe
410 INVITE_INVALID, e a conta não é reativada — e um convite normal continua funcionando para quem nasceu inativo, que é o caso de todo convidado (RN11a). É o par que impede a regra de matar o fluxo que ela deveria preservar. É a quarta porta de sessão, e a única que a RN11 não fecha gravandois_active. - [x] CA12b. Um usuário excluído não consegue fazer login com a senha dele — nem depois de a verificação de duplicidade passar a enxergar excluídos (RN14). É o par do CA17, e o que impede o conserto do 500 de reabrir o login.
- [x] CA13. Um
PATCHque muda só o nome não gera evento de auditoria (RN8). - [x] CA14. O perfil escolhido em
POST /usersgeraASSIGN_USERcomat_creation: true, no mesmo formato do evento que já existe —entity_type="role",entity_iddo perfil. Filtrar a auditoria porroletraz as concessões dos dois caminhos. - [x] CA15. Desativar o último usuário ativo com
users:createdevolve403 LAST_ADMIN, e nada é gravado — nem a mudança, nem auditoria. - [x] CA15a. O mesmo para excluir.
- [x] CA15b. Com dois administradores, desativar um passa; desativar o segundo é recusado. É o par que prova que a regra conta, e não que ela recusa sempre.
- [x] CA15c. Um perfil personalizado com
users:createconta para a RN12 — a regra é sobre permissão efetiva, não sobre o papelADMIN_TENANT. - [x] CA15d. Com dois administradores, excluir um e depois desativar o outro é recusado — o excluído não conta como administrador restante (RN12).
- [x] CA15e. O
ADMIN_GLOBALtambém recebe403 LAST_ADMINao tirar o último administrador de um cliente (RN12). - [x] CA16. Desativar a si mesmo devolve
403 CANNOT_TARGET_SELF(RN13). - [x] CA17. Recadastrar o e-mail de um usuário excluído devolve
409 EMAIL_TAKEN, e não 500, nos cinco pontos que verificam e-mail:POST /users, o cadastro público, os dois convites de usuário e o convite de garantidora (RN14).
Isolamento
- [x] CA18.
PATCH,DELETEeGET /users/{id}sobre usuário de outra garantidora devolvem 404, e nada é gravado. - [x] CA19.
ANALYSTeVIEWERleemGET /userse recebem 403 emPOST,PATCHeDELETE.OPERATOReAGENCY_USERrecebem 403 já noGET. - [x] CA20.
GET /userscom token deADMIN_GLOBALcontinua trazendo usuários de todos os tenants e os de imobiliária — a RN5 vale só no ramo do tenant (RN17). É a mesma rota, e é por isso que o critério existe. - [x] CA20a. No mesmo ramo,
search,is_active, o envelope e a ordem funcionam na rota — verificado por chamada de API, não por tela:/admin/userscontinua filtrando em memória e não muda (RN17).
As telas
- [x] CA21. Em 375px, as três telas — Equipe, Perfis e detalhe do perfil — não rolam na horizontal, e nenhum controle fica fora dos 375px.
- [x] CA22. No cartão da Equipe: nome, e-mail, perfis e o selo de situação; rodapé com Ativar/Desativar e Excluir; o corpo leva ao formulário de edição.
- [x] CA23. O
ANALYSTabre a Equipe em 375px e não vê "Novo usuário", rodapé de ações, nem toque que leve a lugar nenhum. - [x] CA24. Quem tem
users:updatee nãousers:deletevê Editar e Desativar, e não vê Excluir (RN16). - [x] CA33a. Quem não tem papel de sistema não vê o botão "Convidar operador" na tela da imobiliária (RN2b, RN16a) — é a única tela cujo porteiro esta spec muda, e sem este critério a promessa de não convidar ao 403 fica sem conferência.
- [x] CA24a. Quem tem
users:updatee não éADMIN_TENANTabre o Editar usuário e não vê o painel "Perfis de acesso" — as rotas de atribuir e revogar são de papel de sistema, e a RN16 sozinha não alcança isso (RN16a). - [x] CA24c. O seletor "Adicionar perfil" do Editar usuário não oferece
AGENCY_USERa umADMIN_TENANT, nem perfil de sistema a quem não tem papel de sistema — os dois viriam comassignable: false, e clicar neles seria 403 garantido (RN16a). - [x] CA24b. Quem não tem
roles:list, ou para quem nenhum perfil vemassignable: true, não vê o seletor de perfil no cadastro — vê a frase (RN16a). Um seletor vazio é o mesmo beco que um seletor negado. - [x] CA25. No detalhe do perfil em 375px, os blocos por recurso vêm fechados, com a contagem no título, e a descrição de cada ação é legível ao abrir.
- [x] CA26. No formulário de perfil, o checkbox fica ao lado do rótulo, nos dois viewports (RN15).
- [x] CA26a. No mesmo formulário, uma permissão que quem edita não pode conceder aparece desabilitada para marcar, com a explicação ao lado — e, se o perfil já a carrega, vem marcada e pode ser desmarcada (RN15a). É o critério de tela que impede o beco do perfil legado.
- [x] CA27. Um usuário sem perfil nenhum, ao entrar, lê a frase que diz o que falta, e o único botão é "Sair" (RN15b).
- [x] CA28. Os textos dos diálogos de Desativar e Excluir são os da 7.1, e o de Excluir avisa que o e-mail não fica livre.
- [~] CA29. A tela de Auditoria oferece
roleentre os tipos de entidade, e filtrar por ele traz as concessões de perfil dos quatro caminhos — atribuição, cadastro, aceite de convite de membro e aceite de convite de imobiliária (RN8). - [x] CA29a. Um
ADMIN_GLOBALque atribui e depois revogaADMIN_TENANTnum cliente gera as duas linhas com otenant_iddaquele cliente, e elas aparecem na Auditoria dele (RN8). Hoje as duas gravariam tenant nulo — perfil de sistema eADMIN_GLOBALsão nulos os dois — e sumiriam justamente de quem foi afetado. - [x] CA30. A descrição do
ASSIGN_USERtraz o nome do usuário, e não o UUID (RN9). - [x] CA31. Um perfil de outra garantidora devolve 404, e não 403, nas cinco superfícies que a RN14a alcança:
GET /roles/{id},PATCH,DELETE, atribuir e revogar. - [x] CA32.
GET /rolesdevolveassignable: falsenos perfis que quem chama não pode conceder, e a API recusa de novo se o campo for ignorado. - [x] CA33. Um perfil personalizado com
users:createe sempartner_agencies:updaterecebe 403 emPOST /onboarding/partner-agencies/{id}/invite; um perfil compartner_agencies:updatetambém — é o resíduo que a primeira leitura da RN2b deixava aberto —; e umADMIN_TENANTcontinua convidando. É o trio que prova que o porteiro novo fecha a escalada sem quebrar a parceria. - [x] CA34.
POST /onboarding/userscomrole: "AGENCY_USER"devolve 422 — o papel saiu doInvitableRole(RN3) —, e com um papel que quem convida não tem devolve403 ROLE_NOT_ASSIGNABLE(RN2a). - [x] CA35. Um
ADMIN_TENANTnão consegue criar nem editar perfil incluindocredit_analyses:withdraw, que ele não tem. UmADMIN_GLOBALcontinua podendo. - [x] CA35e.
GET /permissionsdevolvecredit_analyses:withdrawna lista para oADMIN_TENANT, comgrantable: false— e não a omite (RN2c). Para oADMIN_GLOBALela vemgrantable: true. É o campo que impede o beco, e por isso tem critério próprio. - [x] CA35f. Um perfil legado que já carrega
credit_analyses:withdrawpode ser salvo peloADMIN_TENANTmantendo a permissão (não é recusado) e removendo-a (é aceito) — e não pode ganhá-la de volta depois (RN2c). - [x] CA35a. O mesmo
ADMIN_TENANTconsegue pôrtenants:readetenants:updatenum perfil personalizado — ele as tem, e a contenção substituiu a lista de recursos (RN2c) —, e não consegue pôrtenants:list, que é de plataforma e ele não tem. - [x] CA35b. Editar um perfil que já carrega uma permissão fora do alcance de quem edita preserva essa permissão, e o
PATCHnão é recusado — salvar só o nome não remove nada em silêncio (RN2c). - [x] CA35c.
DELETE /roles/{id}de um perfil que alguém está usando devolve409 ROLE_IN_USEcom a contagem, e ninguém perde acesso (RN14b). Sem portadores, devolve 204. - [x] CA35d.
POST /rolescom umpermission_idinexistente devolve 422, e não 201 com a permissão ignorada em silêncio (RN2c). - [x] CA36. O aceite de convite grava
ASSIGN_USERcomfrom_invite: true, o ator sendo quem convidou e otenant_iddo usuário convidado — o código de verificação não guarda tenant. A linha aparece na tela de Auditoria, que filtra por tenant (RN8). - [x] CA37. Revogar o perfil do último administrador devolve
403 LAST_ADMIN, inclusive quando quem chama é oADMIN_GLOBAL— é a terceira porta da RN12, e a única que a guarda de auto-alvo não cobre.
14. Plano de implementação¶
apps/api, e a RN2a antes da RN1 — a contenção primeiro, a abertura depois. Dentro da fase, nesta ordem:- O que já está no ar: a RN2d primeiro, e sozinha — papel de sistema reconhecido por
is_systeme nome reservado. É o único defeito da spec que hoje dá a chave da plataforma a qualquer cliente, e não depende de mais nada desta spec para ser corrigido. Antes, a consulta que confere se algum tenant já tem perfil com nome de papel de sistema. - As travas: contenção ao conceder (RN2a) e ao montar (RN2c), perfil de sistema só por papel de sistema (RN2e),
ADMIN_GLOBAL(RN2),AGENCY_USERfora do cadastro e doInvitableRole(RN3), o porteiro do convite de imobiliária (RN2b) e o 404 do perfil alheio (RN14a). - A abertura: a guarda do cadastro sai (RN1), o padrão do
roleviraNone(RN4) e nome inexistente vira422(RN3a). - A lista: busca, filtro, envelope, exclusão de parceira, ordem, e o ramo do
ADMIN_GLOBAL(RN5–RN7, RN17). - A auditoria: os cinco eventos, o do aceite de convite, e a descrição do que já existe (RN8, RN9).
- Tirar acesso:
is_activena exclusão (RN11), a RN12 nos três caminhos, a RN13 e a consulta própria de duplicidade (RN14).
Testes por RN, com os negativos da 10.1. Swagger. E o downgrade exercitado de verdade — up, down, up —, que desde a 0028 é o caminho de volta de um deploy ruim e não mais só higiene. 2. apps/web — o RecordCard de duas alturas primeiro, porque as três telas dependem dele; depois Equipe, Perfis, detalhe do perfil, o display: block e a tela de recusa. Desktop e mobile no mesmo passo. 3. apps/e2e — o spec do fluxo, com o ANALYST e o perfil customizado sem users:delete.
A ordem importa por um motivo, e ele é interno à primeira fase: a RN2 e a RN2a precisam existir antes da RN1, porque a RN1 sozinha é uma escalada de privilégio — sem a contenção, qualquer perfil com users:create cria um ADMIN_TENANT. Um commit que só tire a guarda do cadastro não pode chegar à main.
O envelope da lista quebra o contrato, mas os dois clientes já o toleram, então isso não ordena nada — a fase de API vem antes pelo motivo de sempre: o contrato desce.
15. Decisões em aberto¶
Nenhuma. As vinte e duas decisões tomadas durante a escrita estão registradas nas seções 5, 7 e 9, todas com data.
Riscos aceitos. Todos foram aceitos por quem pediu a spec ao decidir, em 2026-08-20, seguir com ela inteira em vez de partir — a alternativa oferecida era 0041 só de viewport e uma 0042 com a superfície de acesso. Os que têm decisão própria trazem a data na linha.
- A auditoria continua sem responder "por quê". Guarda quem e quando (RN10). Quem quiser o motivo continua indo à conversa que aconteceu fora do sistema.
- Ler a auditoria no celular continua fora (decisão do usuário). Quem pergunta "quem tirou o acesso de fulano?" passa a ter o que ler, e continua precisando de um computador.
- Trocar o e-mail continua impossível, e agora com uma volta a mais: excluir e recadastrar esbarra no
409da RN14. A spec torna o erro honesto; não abre o caminho. - Um usuário de imobiliária que perca o perfil fica sem conserto pela tela. A RN3 fecha
AGENCY_USERnos três caminhos de concessão, e a RN5 tira essa pessoa da lista da Equipe — então uma revogação por engano (oREVOKE_USERexiste e é alcançável) deixa uma conta sem perfil nenhum, sem tela que a conserte, e recadastrar esbarra na RN14, que agora ocupa o e-mail. É consequência de duas decisões registradas, e o conserto é o mesmo que a Parte I já aponta: administrar usuário de imobiliária na tela de Imobiliárias, que é outra spec. - A ordem alfabética muda o conteúdo de
/admin/usersacima de 200 usuários. A tela carregalimit=200e filtra em memória, e não muda nesta spec (RN17) — mas os 200 que ela recebe deixam de ser "os mais recentes" e passam a ser "os primeiros em ordem alfabética". Sem efeito hoje (produção sem cliente, 17 usuários no dev-remote), e é mudança de comportamento numa tela declarada intocada. Adequá-la é a outra spec que a RN17 já aponta. - Há uma quarta porta para esvaziar a administração de um tenant, e ela fica aberta: editar o perfil personalizado que carrega
users:create(PATCH /roles/{id}) — ou apagá-lo (DELETE /roles/{id}) — tira a permissão de todo mundo que o tem, com o mesmo efeito de revogá-lo de cada um. Só oADMIN_GLOBALa alcança de forma útil — umADMIN_TENANTtemusers:createpor si e não se esvazia editando perfil personalizado. É estreita, e é o mesmo raciocínio que fez a RN12 incluir a revogação; fica registrada em vez de fechada porque simular o efeito de uma edição de perfil sobre todos os portadores é trabalho desproporcional ao caminho. - O
ADMIN_TENANTainda pode convidar uma imobiliária com um endereço que ele controla, e a conta resultante opera comcredit_analyses:withdraw— a permissão que a RN2a diz que ninguém naquela garantidora concede. A RN2b fecha o degrau delegável; este é o próprio gestor, e fechá-lo exigiria proibir que ele convide, que é a função da tela. O ganho é de proveniência e não de poder: ele já temcredit_analyses:cancel, e a conta nasce presa a uma imobiliária. - Um usuário de imobiliária que ganhasse permissões de usuário por perfil personalizado administraria a equipe interna. Com
users:listele lê a equipe inteira; comusers:create,users:updateeusers:deleteele cria, desativa e exclui gente da garantidora — e a RN12 explicitamente não o conta como administrador, então ele nem segura a contagem. A RN5 filtra a lista porpartner_agency_ide não por quem pergunta: quem tem vínculo sai da lista, e quem consulta não é filtrado. E com a RN2c extinguindo a lista de recursos não-delegáveis,tenants:updateentra na mesma conta — o mesmo ato deliberado do gestor daria a alguém do canal oPATCH /onboarding/tenant, que é a rota que a permissão sozinha abre. Hoje ninguém está nessa situação e só um ato deliberado do gestor a cria; fechar exigiria decidir o que a Equipe devolve a quem é do canal, que é a mesma spec que a Parte I já mandou para depois. - O
409 EMAIL_TAKENpassa a revelar também os excluídos, e o e-mail é único na plataforma inteira. Quem temusers:create— e o cadastro público — pode sondar se um endereço já existiu em qualquer garantidora. É comportamento de hoje para usuário vivo, ampliado aqui para o excluído, e o preço de não ter índice parcial. Trocar por um erro genérico esconderia a informação e devolveria o beco que a RN14 veio consertar. - A RN2a compara conjuntos de permissão, e isso tem um efeito colateral aceito: dois perfis personalizados com permissões diferentes e igualmente amplas não se concedem mutuamente. É restritivo demais em teoria, e na prática só aparece se alguém montar dois perfis administrativos disjuntos — cenário que o produto não tem hoje.
- A RN12 conta
users:create, e não "quem consegue administrar" — e o que se perde é maior do que parece. Num tenant com umADMIN_TENANTe alguém de perfil personalizado comusers:create, a regra autoriza desativar ou excluir oADMIN_TENANT: ela vê um portador restante e libera. O que a garantidora perde nesse instante, tudo porrequire_system_role: criar, editar e excluir perfil; atribuir e revogar perfil;GET /permissions;PATCH /tenant-settings— taxas, LMI, carência e dias de vencimento, que é o parâmetro do negócio;GET /documents/storage-summary; editar os dados da própria garantidora; e — por causa da RN2b, que esta spec introduz — convidar imobiliária. Sobra criar usuários que nunca poderão receber perfil, e a saída volta a ser o administrador da plataforma.
A conta exata exigiria decidir o que é "administrar" — se é papel de sistema, se é um conjunto de permissões, o que fazer com perfis personalizados amplos —, e essa é discussão maior que o risco. Fica a aproximação, com o que ela deixa passar dito por extenso, e não como antes ("não consegue montar perfil"), que descrevia menos do que acontece. - O formulário de perfil segue sendo tarefa de mesa (decisão do usuário). Em 375px ele funciona com esforço, e a spec não o redesenha. - A administração de usuário de imobiliária deixa de existir em algum lugar. Ela existia por acidente na Equipe (RN5), e construí-la na tela de Imobiliárias é outra spec. Registrado na Parte I.
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 | — | — | — | — |
Resultado: aprovada na 7ª rodada — 0 Critical, 0 Important, 5 Minor.
O laço rodou sete vezes, e não é o caso da spec 0036. Lá ele ficou preso no mesmo ponto, e o que travava eram decisões de negócio. Aqui nenhuma rodada repetiu achado: cada uma achou uma coisa nova e verdadeira sobre o produto, e quatro delas mudaram o escopo. O sinal não é de spec mal escrita — é de uma área que estava mais quebrada do que o pedido supunha. O pedido era "adequar a tela da equipe no mobile"; o que apareceu no caminho foi que o gestor não consegue cadastrar ninguém, que o filtro de situação nunca filtrou, que tirar acesso não deixa rastro, e que a lista traz mais gente de imobiliária do que da equipe.
O que virou método, e vale para as próximas: as três primeiras rodadas discutiram texto; a partir da quarta o revisor passou a executar contra o ambiente, e foi aí que os defeitos apareceram. Medir layout já era hábito (as coordenadas de cada coluna estão na seção 3); medir comportamento não era.
Pendências aceitas:
- A dor da busca encolhe com a separação dos dois públicos — de 17 pessoas para 7. A busca continua no escopo, e a razão principal passa a ser a paridade: é a única lista de negócio do produto sem ela.
- O 500 do recadastro com e-mail de usuário excluído está anotado no fora de escopo como material da Parte III — esta spec é a que torna o caminho alcançável.
- A visão do
ADMIN_GLOBALsobre/users(que não filtra tenant) precisa de uma decisão na Parte III sobre o corte porpartner_agency_id. Não é da Parte I.
Riscos aceitos. Todos foram aceitos por quem pediu a spec ao decidir, em 2026-08-20, seguir com ela inteira em vez de partir — a alternativa oferecida era 0041 só de viewport e uma 0042 com a superfície de acesso. Os que têm decisão própria trazem a data na linha. risco de acesso que a revisão apontou e se decidiu não fechar nesta spec, com quem decidiu e por quê. Vazio se não houver — e o esperado é que esteja vazio.
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 | 1 | 5 | 7 | Reprovado |
| 2 | apps/api | 0 | 0 | 9 | Aprovado |
| 3 | apps/web | 0 | 4 | 7 | Reprovado |
| 4 | apps/web | 1 | 2 | 4 | Reprovado |
| 5 | apps/web | 0 | 3 | 3 | Reprovado |
| 6 | apps/web | 0 | 1 | 6 | Reprovado |
| 7 | apps/web | 0 | 1 | 6 | Reprovado |
| 8 | apps/web | 0 | 0 | 6 | Aprovado |
| 9 | apps/e2e | 4 | 4 | 3 | Reprovado |
| 10 | apps/e2e | 0 | 2 | 5 | Reprovado |
| 11 | apps/e2e | 0 | 0 | 5 | Aprovado |
| 12 | branch | 0 | 1 | 7 | Reprovado |
| 13 | branch | 1 | 0 | 4 | Reprovado |
| 14 | branch | 0 | 0 | 3 | Aprovado |
Rodada 1 (apps/api) — o que foi apontado e o que se fez.
O Critical foi de teste, não de código: as duas asserções que diziam cobrir a RN12 no PATCH e no DELETE miravam a própria conta de quem chamava, então quem respondia era a guarda de auto-alvo (RN13) — que já existia antes desta branch. Com in ("LAST_ADMIN", "CANNOT_TARGET_SELF"), as duas passariam com a RN12 inteiramente removida. Os quatro testes de RN12 foram reescritos com o ADMIN_GLOBAL como terceiro, que é também o caso real: dentro do tenant o último administrador não consegue se tirar, e a porta que a regra fecha é a de fora. Verificado revertendo a guarda — quatro vermelhos, e verdes de volta ao restaurar.
Os cinco Important: (a) o CA4d usava a fixture admin_token, que concede todas as permissões — a contenção passava sozinha e a isenção do ADMIN_GLOBAL podia ser removida sem o teste acusar; passou a usar o ADMIN_GLOBAL do seed, e o red-green confirma; (b) a RN14 unificou a consulta de e-mail e não a mensagem — os quatro textos viraram EMAIL_TAKEN_MESSAGE, um só, e o conselho de "Esqueci minha senha" saiu, porque a redefinição é silenciosa para quem tem deleted_at; (c) o CA17 cobria um dos cinco pontos, e agora cobre os cinco; (d) o Swagger das rotas que ganharam recusa nova não fora atualizado, e o _ROLE_CHOICES ainda anunciava ADMIN_GLOBAL e AGENCY_USER — os dois que a RN2 e a RN3 acabaram de tornar inconcedíveis; (e) o tenant_id do alvo nos eventos ASSIGN_USER/ REVOKE_USER não tinha teste, e agora tem (CA29a e CA30), verificado por reversão.
Dos Minor: a auditoria de exclusão de perfil passou a vir depois da guarda ROLE_IN_USE; os nomes reservados saem de Role.is_system no banco, e não de uma lista escrita à mão que envelhece calada; % e _ na busca passaram a ser caractere e não curinga; administrador já inativo deixou de contar para a RN12 (a recusa era em nome de um poder que a conta não tem, e sem saída pela interface); e entraram os testes de CA4a1, CA5 e das duas superfícies de CA31 que faltavam. O N+1 do assignable fica como pendência aceita: é uma consulta por perfil sobre uma lista pequena por desenho.
Rodada 14 (branch inteira) — aprovada, com três Minor, todos fechados.
O cartão e a linha de perfil navegavam para o detalhe sem porteiro, e a rodada 13 tinha acabado de pôr roles:read na rota: quem tivesse roles:list sem roles:read — combinação que a RN2c torna montável — tocava o cartão e caía na recusa. O controle que leva até a rota passou a ter a mesma régua dela.
Na falha de carga da Equipe, total ia a zero e a FilterBar anunciava "0 usuários" debaixo da faixa que avisa que não conseguiu ler — a contagem some junto, em vez de virar afirmação falsa.
E o perfil legado do e2e nascia do ADMIN_GLOBAL, que grava tenant_id nulo: perfil de tenant nulo aparece na lista "Personalizados" de todas as garantidoras, e ficaria no ambiente compartilhado sem ninguém para removê-lo. Agora ele nasce do gestor — fica na garantidora de e2e — e o ADMIN_GLOBAL só o amplia com a permissão que o gestor não pode conceder, que é o que faz dele um perfil legado. O artefato deixado pela versão anterior foi removido do dev-remote.
Resultado: aprovada na rodada 14 da revisão de branch.
Defeitos aceitos:
- N+1 no
assignabledoGET /roles— uma consulta de permissões por perfil, sobre uma lista pequena por desenho. - A
FilterBarfica dentro do painel, e não acima dele. Há os dois padrões no app — Imobiliárias põe fora, Inquilinos põe dentro —, e Inquilinos é a tela que esta spec manda seguir no mobile. Unificar as duas é assunto de outra spec. - O deslocamento do toast em 375px vale para o app inteiro, então em tela sem paginação ele flutua ~64px mais alto que o necessário. Corrigir de verdade exigiria posicionamento por tela, que é mudança maior que o desvio.
- O cabeçalho do detalhe do perfil não sobrevive ao carregamento — a 7.3 diz que ele fica, mas nome, identificador e tipo vêm da mesma requisição que está carregando. Atender ao texto exigiria uma segunda fonte de dado, que a spec não descreve.
[SPEC INCOMPLETA]reconhecida. - Duas coberturas por caminho, e nenhuma de regra: a RN2e no convite de membro (a CA4e cobre
POST /users; o julgamento é o mesmo objeto compartilhado) e a auditoria do aceite de convite de imobiliária (mesmo bloco deaccept_invitejá coberto pelo convite de membro). - Quatro decorators sem
response_description, dívida anterior a esta branch.
Rodada 2 (apps/api) — aprovada, com nove Minor.
Fechados na hora, porque custavam segundos: import morto (pode_conceder no permission_service), três blocos de import fora de ordem, o Swagger do POST /onboarding/users — a única rota tocada pela spec que ficara sem a tabela de erros —, a asserção que faltava no CA2 (cadastro sem perfil não grava concessão) e a da ordem alfabética no ramo do ADMIN_GLOBAL (CA20a).
Pendências aceitas, todas de cobertura por caminho e nenhuma de regra, porque o julgamento é compartilhado em services/role_granting.py e já tem teste dirigido pelo caminho do cadastro: a RN2e no convite de membro (CA4e cobre POST /users; no convite quem recusa no teste existente é a contenção), a auditoria do aceite de convite de imobiliária (CA29 — mesmo bloco de accept_invite já coberto pelo convite de membro), e quatro decorators sem response_description, que é dívida anterior a esta branch. Some-se o N+1 do assignable em GET /roles: uma consulta de permissões por perfil, sobre uma lista pequena por desenho.
Rodada 3 (apps/web) — o que foi apontado e o que se fez.
Sem Critical. Os quatro Important eram todos de comportamento que o comentário do código afirmava e o código não tinha: (a) os blocos por recurso do detalhe do perfil nasciam abertos, porque o padrão do MobileDetailSection é abertaPorPadrao = true e a chamada não o desligava — exatamente a rolagem infinita que a CA25 proíbe; (b) a ListPagination do mobile estava dentro do bloco de conteúdo, e sticky só prende enquanto há rolagem, então numa equipe de três pessoas a barra apareceria colada no terceiro cartão; (c) o data-testid dos dois seletores de perfil ia para o input escondido do MUI, que não recebe clique — o repositório já tinha o padrão certo (SelectDisplayProps) em duas telas; (d) nenhuma das três ações confirmava com toast, e o caso que a jornada nomeia é real: com o filtro "Ativo" escolhido, desativar faz a linha sumir e nada diz o que aconteceu.
Dos Minor, fechados: a segunda linha do vazio-com-filtro e a do perfil sem permissão, o identificador técnico no slot code do cartão (em vez de repetir o título quando não há nome amigável), DEFAULT_PAGE_SIZE no lugar da constante local, e os data-testid que a fase e2e vai dirigir — o "marcar tudo" do grupo, o Salvar/Cancelar do formulário de perfil, os campos Nome e Descrição, e o chip que revoga perfil.
Um achado veio marcado [SPEC INCOMPLETA], e a decisão foi estender a RN16a. "Novo perfil", "Editar" e "Excluir" da tela de Perfis não tinham porteiro de tela, e as quatro rotas correspondentes são gateadas por require_system_role. A RN2c torna o caso alcançável: o ADMIN_TENANT pode delegar roles:list a um perfil personalizado, e quem o receber abre /roles — leitura legítima — e veria três botões que garantem 403. Os três passaram a ficar atrás de papel de sistema, pelo mesmo princípio dos dois que a RN16a enumera. A leitura continua inteira; some o que não ia funcionar.
Rodada 4 (apps/web) — um Critical, e ele era o oposto do que o código dizia.
O comentário do formulário de usuário afirmava que a tela "engole" o 403 do GET /roles. Ela não engolia: era expulsa por ele. O apiReq redireciona para /unauthorized em qualquer GET recusado antes de rejeitar a promessa, então o .catch() chegava tarde. O efeito é justamente o contrário do que a RN16a desenha — o perfil "Recepção" (users:create sem roles:list) não veria o seletor sumir com a frase explicativa; veria a tela de recusa, e não conseguiria cadastrar ninguém. O conserto é o tolerate403, que existe no repositório e nasceu do mesmo defeito com o GET /settings expulsando o analista da análise de crédito.
O mesmo padrão aparece em GET /permissions, no formulário de perfil, e ali a decisão foi oposta: sem a lista de permissões não há formulário — montar perfil é escolher permissão —, então ser levado à recusa é o certo. O que mudou lá foi o catch mudo, que agora fala.
Os dois Important eram requisitos escritos da Parte II sem código: em 375px o toast cobria a ListPagination (as duas coisas ancoram no mesmo canto, e o toast aparece justamente quando a linha some pelo filtro — o momento em que a pessoa vai querer navegar), e o detalhe do perfil não tinha "Tentar novamente" no erro.
Dos Minor: a régua do bloqueio da RN15a passou a ser o conjunto salvo e não o marcado agora — medindo contra o marcado, desmarcar uma permissão legada a bloqueava no mesmo render e o toque errado só se desfazia recarregando; a caixa "marcar tudo" de um grupo inteiramente bloqueado ficava clicável sem efeito; e o "Carregando…" só aparecia com a lista vazia, então trocar de página ou de filtro mantinha as linhas antigas sem indicação nenhuma.
Rodada 5 (apps/web) — duas das três correções da rodada anterior criaram o achado seguinte.
É o sinal que o CLAUDE.md descreve: o achado da rodada N cita uma linha que a rodada N−1 escreveu. O toast subiu 76px para limpar a tab bar de 62 — e a ListPagination, que é sticky bottom: 0 dentro do conteúdo, ocupa os ~52px logo acima dela; o toast passou a cobrir as setas de página, que é exatamente o que a Parte II proíbe. A conta certa tem três parcelas, e agora está escrita no código. E o botão "Desativar" ganhou color="error" de verdade: até esta branch a cor ia dentro do sx, que o MUI descarta, então ele nunca fora vermelho — a correção pintou dois botões vermelhos lado a lado, apagando a distinção entre desativar e excluir que é a razão declarada de os dois terem rótulo em vez de ícone.
O terceiro Important era anterior: a recusa que esta spec cria — 403 LAST_ADMIN — nunca chegava ao diálogo. O apiReq intercepta 403 em método não-GET antes de rejeitar e abre a recusa global "Sem permissão", enquadrando como falta de acesso uma regra de negócio que tem saída escrita ("Dê o perfil a outra pessoa antes."). As três rotas de usuário passaram a usar tolerate403, porque as recusas delas são todas de regra, não de permissão.
Dos Minor: a frase da RN16a aparecia para todo mundo enquanto GET /roles não respondia — allRoles nasce vazio, e sem estado de carregamento o critério ficaria verificável por engano; e o campo de e-mail não era marcado no 409 EMAIL_TAKEN, que a Parte II pede junto da faixa.
Rodada 6 (apps/web) — a terceira porta da RN12 tinha ficado de fora do conserto anterior.
A rodada 5 pôs tolerate403 nas três rotas de usuário, com o argumento de que as recusas delas são regra de negócio e precisam ser lidas onde a ação foi pedida. Revogar perfil ficou de fora — e é justamente a terceira porta da RN12, a única que a guarda de auto-alvo não fecha: o ADMIN_GLOBAL passa por ela e esvaziaria a administração de um cliente (CA37). Sem o tolerate403, o apiReq abre o modal global "Sem permissão" e a mensagem que chega ao painel é string vazia, então o painel não mostra nada: a pessoa lê "entre em contato com o responsável" no lugar de "dê o perfil a outra pessoa antes". O CANNOT_TARGET_SELF do mesmo caminho é ainda mais alcançável — basta abrir o próprio Editar e clicar no X do chip.
Dos Minor: o "Tentar novamente" da lista de Perfis não limpava a faixa vermelha (a tela de detalhe já limpava, e as duas ficavam incoerentes); o "Carregando…" saía duas vezes, uma por bloco, quando são duas seções da mesma requisição; o portão de rota lia system_roles sem o ?? [] que todos os outros leitores têm; e a célula de Ações saía vazia para quem tem só users:delete, na própria linha — a coluna existe porque há gente a excluir, e sobre si mesmo não sobra botão nenhum.
Rodada 7 (apps/web) — o quarto controle de escrita de perfil.
A rodada 3 estendeu a RN16a e pôs os três controles da lista de Perfis atrás de papel de sistema. O detalhe do perfil tem um quarto — o "Editar" do cabeçalho —, e ele ficou de fora. Não é só um botão que dá 403: /roles/{id}/edit carrega GET /permissions, que é gateado por papel de sistema e sem tolerate403 por decisão da rodada 4, então o clique tira a pessoa da tela em que ela estava e a joga na recusa.
Os Minor fechados: create e update de perfil também passaram a tolerar o 403, para o PERMISSION_NOT_GRANTABLE da RN2c ser lido no formulário; o estado de erro deixou de conviver com o de vazio ("Nenhum usuário encontrado" embaixo da faixa que avisa que não conseguiu ler são duas afirmações, e a segunda é falsa); os diálogos deixaram de piscar "undefined" durante a animação de fechamento, que é onde o texto longo da CA28 é lido; a linha de perfil personalizado passou a oferecer "Ver" para quem não pode escrever, em vez de célula vazia; e mudar busca ou filtro fora da primeira página deixou de disparar duas requisições.
Rodada 8 (apps/web) — aprovada, com seis Minor.
Fechados: a falha de recarga passou a limpar a lista nos dois viewports (o desktop já fazia; no mobile os cartões da carga anterior ficavam sob a faixa vermelha, que é um dado ao lado do aviso de que não se conseguiu lê-lo); a página encolhe junto com o total, senão excluir o único registro da última página dizia "nenhum usuário encontrado" com a contagem em dezenas; o diálogo da lista de Perfis ganhou o mesmo cuidado da Equipe com a animação de fechamento; e o data-testid do bloco por recurso passou a usar a chave técnica e não o rótulo humano — com acento e espaço, ele faria o e2e selecionar por role-resource-Análises de Crédito, que quebra na primeira melhoria de redação.
Pendência aceita, além da do toast: a FilterBar fica dentro do painel, e não acima dele. Há os dois padrões no app — Imobiliárias põe fora, Inquilinos põe dentro —, e Inquilinos é a tela que esta spec manda seguir no mobile; unificar as duas é assunto de outra spec.
Pendência aceita: o deslocamento do toast em 375px (62 da tab bar + 52 da paginação + 12) vale para o app inteiro, então em tela sem paginação ele flutua ~64px mais alto que o necessário. Corrigir de verdade exigiria posicionamento por tela, que é mudança maior que o desvio.
Fica em aberto, e é [SPEC INCOMPLETA] reconhecida: a 7.3 diz que o cabeçalho do detalhe do perfil (nome, identificador, tipo) permanece durante o carregamento, mas esses dados vêm da mesma requisição que está carregando. Atender ao texto exigiria carregá-los de outra fonte — o estado da navegação —, o que a spec não descreve. Registrado como pendência aceita.
Rodada 9 (apps/e2e) — quatro Critical, e três deles eram a mesma armadilha.
Dois testes não podiam passar: o seletor de perfil mostra display_name ("Analista de Crédito"), e o teste procurava o identificador técnico; e o bloco fechado do detalhe do perfil recebe manterMontado — para o cabeçalho poder contar sem abrir —, então o conteúdo continua no DOM com altura zero, e not.toContainText falharia contra a implementação correta. A régua ali é visibilidade.
Os outros dois eram verdes por vazio, que é o modo silencioso de um negativo não provar nada: a ausência de AGENCY_USER na lista de opções era verdadeira mesmo com o perfil oferecido, porque essa string nunca aparece na tela; e a prova de que o filtro de situação filtra resolvia no quadro de "Carregando…", antes de o servidor responder — continuaria verde com o defeito original de volta. Agora ela é positiva (a lista fica vazia, com a contagem em zero), e isso foi verificado revertendo: com o is_active ignorado no repositório, o teste fica vermelho; restaurado, verde.
Dos Important, dois eram cobertura ausente que a seção 11 pede por escrito: a recusa do último administrador e o efeito de desativar e excluir — o arquivo abria os dois diálogos e cancelava os dois, provando o texto e não a ação. O da recusa é não-destrutivo por construção: o sujeito é o único administrador da garantidora rival do seed, e a precondição é conferida por API antes de qualquer clique, para o teste falhar em vez de desmontar o mundo se o seed mudar. O terceiro era o teste rotulado CA24c exercitando o seletor do cadastro, quando a CA24c é sobre o "Adicionar perfil" do Editar — agora cobre os dois.
A suíte foi executada: 19 verdes, zero vermelhos, contra o dev-remote reconstruído.
Rodada 10 (apps/e2e) — dois Important, os dois sobre o teste e não sobre o produto.
O primeiro quebraria a suíte na primeira execução contra base limpa: cinco testes montam o mesmo perfil e o mesmo usuário de apoio, o Playwright roda em paralelo, e num ambiente que ainda não os tem dois workers entram juntos no ramo de criação — o índice único do nome recusa o segundo. A montagem passou a ser tolerante a corrida: quem perde relê e usa o que o vencedor deixou pronto. Serializar o arquivo inteiro resolveria também, pagando o dobro do tempo.
O segundo é mais fino e vale registrar: dois negativos de tela não tinham contraparte positiva em suíte nenhuma. agency-invite-button aparecia uma única vez em todo o tests/, num toHaveCount(0); users-new-button, três vezes, todas negativas. Apagar o primaryAction de qualquer uma das duas telas deixaria tudo verde — inclusive a CA33a, que a spec chama de "a única tela cujo porteiro esta spec muda". Entrou um teste que afirma que os dois existem para o gestor.
Dos Minor: a precondição do CA37 passou a paginar (o limit é limitado a 200 na rota, e a listagem do ADMIN_GLOBAL atravessa todos os tenants, então o administrador da rival cairia para fora da primeira página conforme o ambiente cresce); o teste passou a reparar o mundo compartilhado num finally, porque a conferência prévia protege contra o seed mudar e não contra a RN12 regredir; o Alert de erro do formulário ganhou data-testid, para a asserção distinguir "lido no painel" de "lido em qualquer lugar"; e o clique no cartão de perfil passou a cobrir também o personalizado, que é justamente o que estava invertido.
Suíte reexecutada: 20 verdes, zero vermelhos.
Rodada 11 (apps/e2e) — aprovada, com cinco Minor.
Fechados: os números de CA estavam trocados (no §13, CA7 é o filtro e CA9 é a ausência do usuário de imobiliária — quem auditasse a cobertura do CA9 encontraria o teste errado); o mapa de rótulos passou a ser conferido antes de virar negativa, porque um rótulo que não resolvesse faria not.toContain(undefined) procurar a string "undefined" e passar por vazio — o mesmo modo de falha que a rodada 9 corrigiu; e o arquivo declarou test.setTimeout(90_000), sem o qual os { timeout: 30_000 } das esperas de tela nunca poderiam ser honrados, já que a montagem de cada sujeito custa uma sequência de logins antes de a página abrir.
E entrou o teste que o revisor apontou como o mais consequente dos que faltavam: a segunda metade da CA26a, que a spec chama de "o critério de tela que impede o beco do perfil legado". Um perfil que carrega permissão fora do alcance de quem edita vem com a caixa marcada e habilitada — sem isso ele seria ineditável para sempre por aquela pessoa, e nem renomeá-lo daria. Quem monta o perfil é o ADMIN_GLOBAL, porque o ADMIN_TENANT não consegue montá-lo: é como esses perfis existem de verdade.
Suíte final da fase: 21 verdes, zero vermelhos.
Rodada 12 (branch inteira) — um Important, e era o que só a visão de conjunto encontra.
A única superfície de isolamento desta spec ficou sem prova. O 404 de usuário de outra garantidora em GET/PATCH/DELETE /users/{id} (CA18) não tinha teste em lugar nenhum — a suíte cobria o isolamento da listagem, e nos quatro arquivos novos o "outro" é sempre o segundo gestor do mesmo tenant.
O código estava certo, e o achado não é sobre ele: é sobre esta branch ter pendurado duas guardas novas (RN12 e RN13) exatamente naquele caminho, as duas carregando o alvo por id. Se alguma delas correr antes do filtro de tenant, um ADMIN_TENANT do tenant A recebe 403 LAST_ADMIN no lugar de 404 ao chamar DELETE sobre um usuário do B — e a contagem de administradores vira o oráculo de existência que a §12 diz por escrito ser a razão de a RN12 ser a última guarda. A suíte inteira continuava verde. O teste novo segura a ordem, e foi verificado invertendo-a: com a guarda antes do escopo, ele fica vermelho.
Dos Minor: GET /roles/{id} devolvia grantable: true em todas as permissões, para qualquer chamador — ninguém lê o campo ali hoje, e é por isso que a divergência passaria despercebida até o primeiro consumidor; e_o_ultimo_administrador mantinha duas consultas à mão que o _administra da revogação já expressa; o useUltimoNaoNulo nasceu duplicado em duas telas e virou shared/hooks/; a falha de carga da Equipe limpava a lista e não a contagem, deixando "9 usuários" debaixo da faixa que avisa que não conseguiu ler; um ramo morto na tabela de Perfis; e o Alert do formulário de perfil ganhou data-testid, que é onde o PERMISSION_NOT_GRANTABLE é lido.
Rodada 13 (branch inteira) — um Critical, e ele só aparecia com as três camadas juntas.
O filtro de tenant do GET /users passou a ser escolhido pela nulidade do tenant_id, e não pelo papel. A RN17 define os dois ramos por is_admin_global; o listar() só acrescentava User.tenant_id == tenant_id quando o valor não era nulo. Um usuário sem garantidora que não seja ADMIN_GLOBAL — e o ADMIN_GLOBAL pode criar um, porque a conta nova não herda o papel dele — cairia no ramo do tenant sem predicado nenhum e leria a plataforma inteira. Na main a mesma chamada compilava para WHERE tenant_id IS NULL e devolvia só quem não tem garantidora: o alcance saiu de "os sem tenant" para "todo mundo".
Não é escalada alcançável por usuário de garantidora, e nenhum caminho de tenant produz essa conta — mas é o filtro de isolamento afrouxado em relação à main, e a régua deste repositório trata isolamento por garantidora como Critical independentemente da facilidade de alcance. O recorte passou a sair de todos_os_tenants, explícito, e o teste novo foi verificado revertendo: com a leitura pela nulidade de volta, ele fica vermelho.
Dos Minor: as rotas de escrita da tela estavam atrás da permissão de leitura — um ANALYST que digitasse /users/create recebia o formulário inteiro com um Salvar que é 403 garantido, e quem tivesse roles:list sem roles:read era expulso para a recusa ao tocar num cartão que esta spec acabou de tornar clicável; cada rota passou a pedir o que ela exerce, e as duas de escrita de perfil pedem papel de sistema, que é o porteiro real delas na API. A CA19 ganhou a metade que faltava (PATCH/DELETE para ANALYST e VIEWER, GET para OPERATOR e AGENCY_USER). E a metade de tela da CA29 — role no filtro de entidade da Auditoria — ganhou teste e2e: é ela que torna a concessão achável, e sem isso a RN8 grava e ninguém encontra.
18. Registro¶
Fase apps/api — concluída¶
O downgrade foi exercitado contra o PostgreSQL do dev-remote, e não só escrito: z9b1d3g5j7k8 → downgrade -1 → y8a0c2f4i6j7 → upgrade head → z9b1d3g5j7k8. Ele derruba a FK nomeada e a coluna invited_by_user_id, e deliberadamente não desfaz o backfill de is_active — voltar a coluna é reversível, mas devolver acesso a quem foi excluído não é o que "reverter um deploy ruim" deveria significar.
O que a spec entregou¶
A dor da Parte I era que a Equipe não funcionava, em dois sentidos que se somavam: cadastrar alguém devolvia 403 por qualquer caminho — com perfil de sistema e com o campo em branco, porque o padrão do contrato também era um perfil de sistema —, e a tela não cabia no celular, que é onde metade do trabalho de administrar acesso acontece. As duas estão fechadas, e a segunda trouxe junto a de Perfis de Acesso, cujo detalhe era uma tabela de 570px num painel de 359 com a Descrição inteiramente fora da tela.
No caminho apareceu uma escalada de privilégio real, e ela estava no ar. Um perfil personalizado chamado ADMIN_TENANT, com zero permissões, abria as oito rotas gateadas por papel de sistema: require_system_role comparava contra o nome. Verificado contra o PostgreSQL do dev-remote (perfil criado, 201 confirmado, apagado em seguida). A correção é a RN2d, em duas camadas — o que decide poder passou a ser is_system, e nome de papel de sistema deixou de estar disponível para perfil personalizado.
A consulta pré-deploy da RN2d ainda não foi feita, e é passo de deploy que o /ship não conhece: antes de subir, conferir se algum tenant já tem perfil personalizado com nome de papel de sistema, para renomeá-lo à mão — senão a lista mostra dois homônimos. No dev-remote não há nenhum; em produção não há cliente, então não há perfil personalizado nenhum.
Divergências do planejado:
- A RN2d cresceu no caminho. O plano previa a camada 2 (nome reservado) como uma lista de seis nomes escrita no serviço. Ela virou consulta a
Role.is_systemno banco: uma lista à mão envelhece calada — papel de sistema novo norbac_seed.pyentraria em vigor sem entrar na reserva. - A RN12 ganhou uma distinção que a spec não previa. Administrador já inativo não conta: a recusa seria em nome de um poder que aquela conta não tem, e deixaria uma conta inexcluível sem saída pela interface.
- Três consertos de coleção obsoleta do SQLAlchemy entraram por necessidade, não por desenho:
RoleRepository.permission_keyspor consulta explícita (a relaçãoRole.permissionsfaria a contenção da RN2a falhar aberta com conjunto vazio),populate_existing=Trueemget_user_roles_with_permissions, eexpire(role, ["permissions"])depois do_sync_permissionsbruto. - A RN2d não pôde ser testada ponta a ponta em SQLite. O índice parcial que permite o homônimo degrada para UNIQUE global no SQLite dos testes, então os testes da RN2d são de unidade sobre o modelo e a dependência; a escalada real foi verificada contra o PostgreSQL do dev-remote (perfil
ADMIN_GLOBALcustomizado criado, 201 confirmado, e apagado em seguida).