Pular para conteúdo

0034 — Isolamento de tenant e de imobiliária, e login sem credencial pré-preenchida

Status CONCLUÍDA
Branch spec/0034-isolamento-de-tenant-e-login
Criada em 2026-08-10
Apps afetados api · web · e2e

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

1. Problema

Duas dores, ambas de confiança na plataforma.

A tela de login entrega a chave da casa. Quem abre /app/login encontra os campos já preenchidos com admin@garantora.com.br e Admin@1234! — a conta de administrador global do SaaS (apps/web/src/pages/login/index.tsx:34-35). Era conveniência de desenvolvimento que atravessou para o ambiente que está no ar. Basta clicar em Entrar.

O isolamento entre empresas não é uniforme. A promessa do produto é que cada garantidora só enxerga o que é dela, e que cada imobiliária parceira só enxerga os próprios negócios. A primeira parte funciona quase toda — o BaseRepository filtra tenant_id. A segunda tem buracos, e há uma classe de falha que fura até a primeira.

O que vaza hoje

Um usuário de imobiliária vê os inquilinos de todas as imobiliárias da garantidora. RenterService filtra só por tenant (renter_service.py:63,103). O usuário da Imobiliária A abre Inquilinos e lê nome, CPF, RG, data de nascimento, telefone e endereço dos clientes da Imobiliária B — sua concorrente na mesma praça.

E baixa os documentos deles. DocumentService também filtra só por tenant. Com o ID do documento, o usuário da A baixa o comprovante de renda, o contracheque ou o RG digitalizado de um inquilino da B.

E consulta o cadastro das outras imobiliárias. GET /api/v1/partner-agencies/{id} responde para qualquer agência do mesmo tenant (partner_agency_service.py:78).

Um administrador de garantidora mexe no perfil de acesso de usuário de outra garantidora. PermissionService.assign_role_to_user e revoke_role_from_user (permission_service.py:177,193) validam o perfil, mas nunca conferem o tenant do usuário alvo. Com o UUID de um usuário da Garantidora B, o admin da A concede ou revoga perfis nele.

Chave estrangeira de entrada não é validada — e isso transforma uma escrita numa leitura de outro tenant. É a falha mais séria, porque o filtro do repositório não a pega: o registro criado é legitimamente seu.

  • CreditAnalysisService.create (credit_analysis_service.py:160) grava o property_id recebido sem nenhuma verificação. A docstring promete NotFoundError se o imóvel não existir no tenant; não há consulta alguma. A FK garante que o imóvel existe — em qualquer tenant.
  • Os renter_id dos participantes idem: a única consulta é a do corresponsável PF, e ela tolera None (segue adiante se o inquilino não for do tenant).
  • Na leitura, get_properties_bulk (credit_analysis_repo.py:157) junta Property por id, sem filtro de tenant — corretamente, já que as análises já vieram escopadas.

Encadeando: um usuário da Garantidora A cria uma análise apontando para o imóvel e o inquilino da Garantidora B, e a tela da própria análise passa a exibir o endereço, o nome do proprietário, o documento do proprietário e o contato — mais o nome e o CPF do inquilino alheio. Não é preciso adivinhar rota nem escalar privilégio: só o UUID.

O que não vaza, mas está quebrado

credit_analyses.partner_agency_id está NULL em 100% dos 2769 registros do dev-remote. O campo só é preenchido quando quem cria é AGENCY_USER (credit_analysis_service.py:159), e o fluxo normal é a garantidora criar. O vínculo real vive no imóvel — properties.partner_agency_id preenchido em 3465/3465, porque é obrigatório no schema.

Efeito: o filtro por imobiliária nas análises (credit_analysis_repo.py:81) devolve zero para um usuário de imobiliária. E garantias e cobranças herdam esse campo (guarantee_service.py:161, billing_service.py:101), então o mesmo vazio se propaga. O escopo que existe no papel não segura nada — nem no sentido de vazar, nem no de funcionar.

E não há barreira

Nenhum teste hoje afirma isolamento de forma sistemática. O próximo endpoint que esquecer o escopo entra sem alarme, exatamente como estes entraram.

2. Objetivo

Que o isolamento seja verdade verificável e não convenção: nenhum usuário alcança dados de outra garantidora, nenhum usuário de imobiliária alcança dados de outra imobiliária, e uma suíte de regressão prova isso a cada make test. E que a tela de login peça credenciais em vez de oferecê-las.

3. Fora de escopo

  • Os 10 papéis area-scoped de docs/navigation-brief.md. Esta spec trabalha com os 6 papéis que existem no código hoje. Reescrever a matriz de papéis é feature própria, e misturá-la aqui esconderia o que é correção de isolamento.
  • Row-Level Security no PostgreSQL. Cogitada como rede abaixo da aplicação. Fica fora: mudaria o modelo de conexão (a sessão precisaria carregar o tenant corrente) e o ganho não se paga antes do primeiro cliente. A barreira desta spec é a suíte, na camada de serviço.
  • Extinguir partner_agency_id de CreditAnalysis e Guarantee em favor de join com properties. Foi a alternativa avaliada para a RN10 e recusada: fonte única seria mais limpa, mas encarece toda listagem com um join a mais. Mantém-se o campo, derivado do imóvel em um só lugar (RN10), que é o que elimina o risco de dessincronizar.
  • Refatorar o escopo para uma dependência única e obrigatória em todos os services. Tocaria quase tudo e transformaria uma correção de segurança numa refatoração ampla, difícil de revisar. A suíte de regressão (RN15) cobre o mesmo risco muito mais barato.
  • Auditoria de acesso a documento (quem baixou o quê). Desejável, é outra spec.
  • 2FA, política de senha, rate limit no login. A tela muda só no que esta spec pede.
  • Rotação da senha do admin global. Já executada pelo usuário em 2026-08-10, fora do código. Registrada aqui só para não parecer pendência.

4. Regras de negócio

Login

  • RN1. Os campos de e-mail e senha nascem vazios. Nenhum valor padrão, nenhum placeholder com credencial real, nenhuma dica de usuário de teste — em nenhum ambiente. Não há variável que reative o pré-preenchimento: o comportamento é único.
  • RN2. O autocomplete do navegador continua valendo (email, current-password). A regra é sobre o que a aplicação preenche, não sobre o gerenciador de senhas do usuário.

Isolamento por garantidora (tenant)

  • RN3. Nenhum endpoint autenticado devolve, cria, altera ou remove registro de tenant diferente do tenant_id do usuário. Única exceção: ADMIN_GLOBAL.
  • RN4. Registro de outro tenant responde 404, nunca 403 — um 403 confirmaria que o ID existe, e isso é enumeração de recurso alheio. A exceção é a operação que o usuário não pode fazer no próprio tenant: aí é 403, porque não há informação vazando.
  • RN5. Toda chave estrangeira recebida no corpo de uma requisição é resolvida dentro do escopo do solicitante antes de ser gravada. ID que não resolve → 404, e nada é persistido. Vale para property_id e renter_id na criação e edição de análise de crédito e de participantes, document_type_id, credit_analysis_id, guarantee_id, partner_agency_id e role_id — e para qualquer FK que venha a existir. É esta regra que fecha a leitura-por-escrita descrita na seção 1.
  • RN6. assign_role_to_user e revoke_role_from_user só operam sobre usuário do mesmo tenant do solicitante. Usuário de outro tenant → 404. Vale também para o ADMIN_TENANT, que hoje passa direto.
  • RN7. Só a interface do SaaS admin (/app/admin/*) enxerga mais de um tenant, e só para quem tem ADMIN_GLOBAL. Nenhuma tela da interface da garantidora aceita escolher ou receber um tenant_id diferente do usuário logado.

Isolamento por imobiliária (partner_agency_id)

  • RN8. Usuário com partner_agency_id preenchido — hoje o AGENCY_USER — só alcança registros da própria imobiliária: inquilinos, imóveis, análises de crédito, garantias, cobranças, documentos e o cadastro da imobiliária. Registro de outra imobiliária → 404, pela mesma razão da RN4.
  • RN9. O inquilino passa a ter partner_agency_id, preenchido na criação com a imobiliária de quem o cadastrou. Cadastro feito por usuário da garantidora grava NULL — é a carteira própria: visível a toda a garantidora e a nenhuma imobiliária.
  • RN10. O partner_agency_id da análise de crédito é derivado do imóvel, sempre, independente de quem cria. O imóvel é a fonte da verdade do vínculo — é onde o campo é obrigatório. A gravação acontece num só ponto do CreditAnalysisService, e o campo nunca é aceito do corpo da requisição.
  • RN11. Garantia e cobrança seguem herdando o vínculo da análise que as originou, como já fazem. Corrigida a origem (RN10), a herança passa a carregar valor.
  • RN12. O partner_agency_id enviado no corpo por um AGENCY_USER é ignorado — vale sempre o vínculo do perfil, como já ocorre em PropertyService. Ele não cadastra em nome de outra imobiliária.
  • RN13. O documento herda o escopo de quem ele pertence. As quatro FKs do Document são mutuamente exclusivas, então há sempre um dono: se o usuário não pode ver o dono, não vê o documento — nem o metadado na listagem, nem o conteúdo no download, nem a contagem no resumo de armazenamento.
  • RN14. O usuário da garantidora (ADMIN_TENANT, ANALYST, OPERATOR, VIEWER) continua vendo todas as imobiliárias parceiras, tudo que elas originaram e também a carteira própria. O isolamento por imobiliária restringe quem é de uma imobiliária, não quem é dono da garantidora.

Barreira de regressão

  • RN15. A suíte da API passa a conter um teste de matriz que, para cada endpoint autenticado de recurso de negócio, tenta alcançar registro de outro tenant e de outra imobiliária e exige 404. O teste descobre as rotas a partir do app.routes do FastAPI, não de uma lista escrita à mão — é o que faz o endpoint novo entrar já coberto.
  • RN16. O mesmo teste exercita a RN5: para cada endpoint de escrita que aceita FK no corpo, envia um ID de outro tenant e exige 404 e ausência de escrita.
  • RN17. Rota que não puder ser exercitada automaticamente entra numa lista de exceção nomeada e justificada dentro do teste. A lista falha o teste se citar rota que não existe mais, para não virar sepultura de dispensas esquecidas.

5. Impacto por app

apps/api

Modelos/migrations

  • Renter ganha partner_agency_id: UUID | None, FK para partner_agencies, indexado e anulável.
  • Migration Alembic com backfill em três passos, medido no dev-remote antes de ser escrito:
Passo Origem Alcance medido
credit_analyses.partner_agency_id properties.partner_agency_id da análise 2769/2769 (100%)
guarantees.partner_agency_id análise de origem, já corrigida 121/121 (100%)
renters.partner_agency_id imóvel da análise mais antiga em que participa 2556/3634 (70%)

Os 1078 inquilinos que restam NULL não têm análise nenhuma e caem na carteira própria (RN9). São aceitáveis: 3633 dos 3634 estão no tenant Garantidora E2E — massa de teste — e produção não tem nenhum inquilino. O downgrade derruba a coluna de renters e zera os dois campos backfillados, e precisa ser exercitado, não só escrito.

Serviços

Arquivo Mudança
services/credit_analysis_service.py Resolve property_id e cada renter_id no escopo antes de gravar (RN5); deriva partner_agency_id do imóvel (RN10)
services/credit_analysis_participant_service.py Mesma resolução de renter_id ao adicionar participante
services/renter_service.py Grava partner_agency_id na criação (RN9/RN12); list e _get_authorized filtram pela agência
repositories/renter_repo.py search/list_with_count aceitam partner_agency_id
services/document_service.py list, get, get_content, update, delete, storage_summary resolvem o dono e aplicam o escopo (RN13)
repositories/document_repo.py Filtro pelo escopo do dono nas listagens
services/partner_agency_service.py list e _get_authorized restringem à própria agência quando o usuário tem partner_agency_id
services/permission_service.py assign_role_to_user/revoke_role_from_user conferem o tenant do alvo (RN6)
services/credit_analysis_document_service.py Conferência por pessoa respeita o escopo da agência

Endpoints — nenhum novo, nenhum removido. Muda o conjunto de resultados e o código de erro no acesso cruzado. Os description dos decorators afetados passam a declarar o escopo aplicado; a matriz de papéis em main.py idem. A docstring de CreditAnalysisService.create deixa de prometer validação que não existia e passa a descrever a que existe.

Testes — tests/test_tenant_isolation.py (novo, RN15–RN17) mais casos dirigidos em test_renters_search.py, test_documents.py, test_credit_analyses.py, test_rbac_security.py e test_partner_agency_search.py.

apps/web

  • pages/login/index.tsx — useState("") nos dois campos (RN1). Só isso; o layout não muda.
  • pages/renters/ — a lista já consome o que a API devolve, então o filtro vem de graça. Muda o formulário: para AGENCY_USER não existe seletor de imobiliária (RN12); para usuário da garantidora, o vínculo aparece como campo de leitura no show, exibindo Carteira própria quando NULL.
  • pages/properties/index.tsx — o filtro por imobiliária (linha 288) e o carregamento da lista de agências (linha 144) não fazem sentido para quem é de uma imobiliária só, e hoje já falham por falta de partner_agencies:list. Passam a ser condicionais ao papel, em vez de erro silencioso.
  • data-testid novos: renter-agency-field, renter-agency-value, properties-filter-agency (marcando o elemento existente, para poder afirmar ausência).
  • Sider: sem módulo novo — App.tsx e components/custom-sider/index.tsx não mudam.
  • Responsividade & PWA: nenhum layout novo, então não há trabalho responsivo próprio — mas a tela de login e o formulário de inquilino são conferidos nos dois viewports, porque o campo de imobiliária entra e sai conforme o papel. PWA: o bundle muda, então a tela instalada só reflete a mudança após rebuild e reabertura; sem impacto em offline ou em assets.

apps/e2e

  • Specs:
  • login-sem-credencial.spec.ts — a tela abre com os dois campos vazios e o botão Entrar com campos vazios não autentica.
  • isolamento-imobiliaria.spec.ts — imob@e2e.test (Imobiliária A) não vê na lista os inquilinos da Imobiliária B; navegar direto para a URL de um inquilino, de um documento e de uma imobiliária da B resulta em não encontrado; e as análises da própria imobiliária aparecem (regressão da RN10, que hoje devolve lista vazia).
  • isolamento-tenant.spec.ts — usuário da Garantidora A abrindo URL de registro da Garantidora B resulta em não encontrado.
  • Seeds: tests/fixtures/seeds/ ganha uma segunda imobiliária com inquilino, imóvel, análise e documento próprios, e um segundo tenant com um usuário e um registro de cada tipo. O seed atual (app/db/seed_e2e.py) tem uma imobiliária só, o que torna o cruzamento inexprimível hoje.

6. Contrato de API

Nenhuma rota nova. As mudanças são de escopo e de código de erro.

Inquilino ganha o vínculo com a imobiliária:

GET /api/v1/renters/{id}
Authorization: Bearer <token>
{
  "id": "uuid",
  "name": "Maria Souza",
  "renter_type": "PF",
  "cpf": "12345678901",
  "partner_agency_id": "uuid-da-imobiliaria | null",
  "created_at": "2026-08-10T12:00:00Z"
}

Na criação é aceito de usuário da garantidora e ignorado de AGENCY_USER (RN12).

FK de outro tenant no corpo — o que passa a acontecer (RN5):

POST /api/v1/credit-analyses
{ "property_id": "uuid-de-imovel-de-outra-garantidora", "participants": [...] }
{ "detail": { "code": "NOT_FOUND", "message": "Imóvel não encontrado." } }

Nenhuma análise é criada, nenhum número de sequência é consumido.

Acesso cruzado — formato uniforme em todos os recursos:

{ "detail": { "code": "NOT_FOUND", "message": "Inquilino não encontrado." } }
Situação HTTP code
Registro de outro tenant 404 NOT_FOUND
Registro de outra imobiliária 404 NOT_FOUND
FK de outro tenant/imobiliária no corpo 404 NOT_FOUND
Operação sem permissão no próprio tenant 403 FORBIDDEN
assign_role sobre usuário de outro tenant 404 NOT_FOUND
Token ausente ou expirado 401 NOT_AUTHENTICATED / SESSION_EXPIRED

7. Critérios de aceite

  • [x] CA1. Dado um navegador limpo, quando abro /app/login, então e-mail e senha estão vazios e o value de ambos os inputs é string vazia no DOM.
  • [x] CA2. Dado o login aberto, quando clico em Entrar sem digitar nada, então a autenticação não ocorre e nenhuma requisição a /api/v1/auth/login é disparada.
  • [x] CA3. Dado imob@e2e.test (Imobiliária A) autenticado, quando abro a lista de inquilinos, então nenhum inquilino da Imobiliária B aparece e o total paginado bate com o número de inquilinos da A.
  • [x] CA4. Dado o token da Imobiliária A e o UUID de um inquilino da B, quando chamo GET /api/v1/renters/{id}, então a resposta é 404 — idem PATCH e DELETE.
  • [x] CA5. Dado o token da Imobiliária A e o UUID de um documento de inquilino da B, quando chamo GET /api/v1/documents/{id} e .../content, então ambas respondem 404 e nenhum byte do arquivo é devolvido.
  • [x] CA6. Dado o token da Imobiliária A, quando chamo GET /api/v1/partner-agencies/{id-da-B}, então 404; com o próprio ID, 200.
  • [x] CA7. Dado um ADMIN_TENANT da Garantidora A e o UUID de um usuário da B, quando chamo POST /api/v1/roles/{role_id}/users/{user_id}, então 404 e nenhum vínculo de perfil é criado no banco.
  • [x] CA8. Dado um usuário da Garantidora A, quando crio uma análise de crédito com property_id de um imóvel da Garantidora B, então a resposta é 404, nenhuma análise é gravada e nenhum dado do imóvel da B aparece em qualquer resposta.
  • [x] CA9. Dado um usuário da Garantidora A, quando crio uma análise com um renter_id de participante pertencente à Garantidora B, então 404 e nada é gravado.
  • [x] CA10. Dado um AGENCY_USER da Imobiliária A, quando abro a lista de análises de crédito, então vejo as análises cujos imóveis são da Imobiliária A — hoje a lista vem vazia — e nenhuma da B.
  • [x] CA11. Dada uma análise criada por usuário da garantidora sobre imóvel da Imobiliária A, quando consulto o registro, então partner_agency_id é o da Imobiliária A, e a garantia emitida a partir dela herda o mesmo valor.
  • [x] CA12. Dado um ADMIN_TENANT da Garantidora A, quando percorro todos os endpoints de recurso de negócio com IDs da Garantidora B, então todos respondem 404 — verificado pelo teste de matriz, não por amostragem.
  • [x] CA13. Dado um OPERATOR da garantidora, quando abro a lista de inquilinos, então vejo os de todas as imobiliárias e também os da carteira própria (RN14).
  • [x] CA14. Dado um AGENCY_USER criando inquilino e enviando no corpo o partner_agency_id de outra imobiliária, quando a criação é aceita, então o inquilino gravado tem o partner_agency_id do perfil do usuário, não o enviado.
  • [x] CA15. Dada a migration aplicada no dev-remote, quando consulto o banco, então credit_analyses.partner_agency_id está preenchido em 2769/2769, guarantees.partner_agency_id em 121/121 e renters.partner_agency_id em 2556, com os 1078 restantes em NULL.
  • [x] CA16. Dado um endpoint de recurso de negócio adicionado sem escopo de tenant, ou aceitando FK sem resolvê-la, quando rodo make test, então o teste de matriz falha nomeando a rota.
  • [x] CA17. Dado alembic downgrade -1 a partir desta migration, quando o comando termina, então renters.partner_agency_id não existe e a API sobe.

8. Plano de implementação

Ordem obrigatória no monorepo: api → web → e2e.

  • [x] apps/api — resolução de FK no escopo antes de gravar (RN5), começando por credit_analysis_service e credit_analysis_participant_service
  • [x] apps/api — partner_agency_id da análise derivado do imóvel (RN10/RN11)
  • [x] apps/api — Renter.partner_agency_id (model → schema → repository → service)
  • [x] apps/api — migration Alembic com o backfill de três passos e downgrade exercitado
  • [x] apps/api — escopo por imobiliária em renter_service/renter_repo
  • [x] apps/api — escopo herdado do dono em document_service/document_repo (RN13)
  • [x] apps/api — escopo da própria agência em partner_agency_service
  • [x] apps/api — conferência de tenant em permission_service (RN6)
  • [x] apps/api — 404 uniforme no acesso cruzado (RN4), revisando os _get_authorized
  • [x] apps/api — tests/test_tenant_isolation.py (matriz por introspecção de rotas)
  • [x] apps/api — testes dirigidos nos pontos corrigidos
  • [x] apps/api — docstrings + Swagger (summary, description, response_description) refletindo o escopo, e a matriz de papéis em main.py
  • [x] apps/web — login sem valor padrão (RN1)
  • [x] apps/web — formulário e show de inquilino com o vínculo de imobiliária, filtro de agência condicional ao papel, data-testid, responsivo (desktop e mobile)
  • [x] apps/e2e — seed com segunda imobiliária e segundo tenant
  • [x] apps/e2e — os três specs de isolamento e login
  • [x] make test e make lint passando (sem apontamento novo nos arquivos tocados)
  • [x] Validado no browser em desktop e mobile, reabrindo a tela instalada para furar o cache do PWA

9. Decisões em aberto

Nenhuma. As quatro que existiam foram fechadas em 2026-08-10:

  • Senha do admin@garantora.com.br em produção — já rotacionada pelo usuário. A credencial esteve visível na tela de login enquanto o ambiente estava no ar; tirar o pré-preenchimento não invalidaria quem já a tivesse lido, por isso a rotação era necessária e independente do código.
  • Vínculo quebrado das análises — corrigido nesta spec, derivando do imóvel (RN10). A alternativa de extinguir o campo e resolver por join ficou registrada em Fora de escopo com o motivo.
  • Carteira própria invisível ao AGENCY_USER — confirmado (RN9/RN14). Inquilino sem vínculo é da garantidora e não aparece para nenhuma imobiliária parceira.
  • Alcance do backfill — medido no dev-remote antes de escrever a migration, e os números estão na tabela da seção 5. Os 1078 inquilinos que ficam NULL são massa de teste; produção não tem inquilino nenhum.

10. Registro

O que apareceu durante a implementação e não estava na spec

Mais três furos, achados pela própria matriz de regressão. A RN15 nasceu como barreira contra o futuro e serviu primeiro contra o presente:

  • GET /users/{id}, DELETE /users/{id} e GET /tenants/{id} respondiam 403 no acesso cruzado, não 404 — violação da RN4 que ninguém tinha notado. Corrigido em user_service._get_authorized e tenant_service._get_authorized.
  • PropertyService.create aceitava partner_agency_id de outro tenant sem validação, mesma classe da RN5 que a spec descreveu só para a análise de crédito. Entrou no escopo por ser idêntico em natureza e trivial em custo.
  • O upload de documento (_validate_entity) conferia só o tenant, então um usuário de imobiliária anexava arquivo ao locatário da concorrente — e, anexando, ganhava um vínculo legítimo com aquele registro. Coberto por teste próprio.

Um erro meu, achado por sabotagem deliberada. Depois de a suíte ficar verde, quebrei de propósito o within_agency do RenterService dentro do container para conferir que a matriz reagia. Ela não reagiu — e a razão era a fixture: cada imobiliária estava sendo criada com o próprio tenant, então o filtro de tenant fazia todo o trabalho e o recorte por imobiliária nunca era exercitado. O cenário foi reescrito para a1 e a2 compartilharem a garantidora; repetida a sabotagem, os testes certos falharam. Sem esse passo, a spec teria entregue uma barreira decorativa.

Também havia um falso positivo no teste de documento: eu usava /documents/{id}/content, rota que não existe — o 404 vinha do roteador, não do escopo. O caminho certo é /download, e o teste agora afirma também que o próprio documento baixa, para distinguir "bloqueado" de "quebrado".

Divergências em relação ao planejado

  • Módulo novo app/services/scoping.py, não previsto. A RN5 se repetia em sete serviços, e a alternativa era espalhar a mesma consulta por todos. Não é a "dependência única obrigatória" que a spec pôs fora de escopo — é um punhado de funções puras que cada serviço chama, sem mudar a assinatura de ninguém.
  • partner_agency_id entrou no /auth/me. Não estava previsto, mas o web precisa saber se o usuário é de imobiliária para omitir seletores, e derivar isso do nome do papel seria frágil (um papel customizado pode ser escopado sem se chamar AGENCY_USER). É dado do próprio usuário, não há leitura de terceiro.
  • A PartnerAgency exigiu um caso especial em visible_to: ela não aponta para uma imobiliária, ela é uma. Sem tratar isso, o usuário de imobiliária deixava de conseguir cadastrar imóvel — a resolução da FK não achava a própria agência.
  • Edição do vínculo do locatário ficou de fora. O campo aparece desabilitado no formulário de edição: mover locatário entre imobiliárias é decisão de negócio que a spec não tratou, e improvisá-la aqui seria inventar regra.

Aferições

  • make test: 279 passaram, 0 falharam. Eram 262 antes da spec; os 17 novos são a suíte de isolamento. Rodada por docker exec garantora-devremote-api-1 uv run pytest, já que não há uv no host.
  • Intermitentes: a CLAUDE.md da raiz documenta test_partner_agency_search.py::test_busca_alcanca_quem_esta_fora_da_primeira_pagina como instável. Na prática são dois — o irmão test_partner_agency_status_filter.py::test_alcanca_quem_esta_fora_da_primeira_pagina tem o mesmo cenário e a mesma causa (empate de created_at em SQLite, desempate por UUID aleatório), e falhou 1 de 3 execuções isoladas. Não tem relação com esta spec.
  • ruff: os arquivos novos (scoping.py, document_repo.py, test_tenant_isolation.py) passam limpos. Os E501 restantes nos serviços tocados são linhas de mensagem preexistentes, não introduzidas aqui.
  • tsc do web: limpo. Atenção para uma armadilha do ferramental: rodar pnpm em apps/web faz o pnpm escrever um pnpm-workspace.yaml com allowBuilds e sem o campo packages, e esse arquivo quebra o pnpm build dentro do web.Dockerfile (ERROR packages field missing or empty). Aconteceu aqui e derrubou o primeiro rebuild. Use ./node_modules/.bin/tsc --noEmit e apague o arquivo se ele aparecer como não-rastreado.
  • Migration: aplicada no dev-remote com os números exatos da CA15 (2769/2769, 121/121, 2556 + 1078). O downgrade foi exercitado: derrubou a coluna e zerou os dois campos; o upgrade seguinte reproduziu estado idêntico.

Pendências

  • A migration ainda não foi aplicada em produção (srv01). É passo do /ship, que é pedido do usuário.
  • e2e escrito, não executado. Nada mais em aberto no código.

Validação no browser (dev-remote, após rebuild e recriação dos containers)

Script de captura nos dois viewports contra https://dev.garantora.com.br/app:

Tela Desktop (1440) Mobile (375)
Login — campos vazios, sem menção à conta de admin ✅ ✅
Formulário de inquilino — campo de imobiliária para o gestor ✅ ✅
Detalhe de inquilino — vínculo exibido ✅ ✅

Sem rolagem horizontal (scrollWidth - clientWidth == 0) e sem erro de console em nenhuma delas.

Dois defeitos apareceram só aqui, e nenhum dos dois o tsc pegaria:

  1. A rota de criação é /renters/**create**, não /renters/new — eu havia escrito new no script e em dois pontos do spec e2e. O goto caía em tela vazia e o "campo ausente" que o script reportou era, na verdade, a página errada.
  2. O detalhe do inquilino tem dois branches de layout, e eu só havia tocado no de desktop. No mobile, montado por MobileDetailSection, o vínculo não aparecia. A linha precisa de ?? "Carteira própria" também porque o .filter((r) => r.value) de lá descartaria a linha sem valor — sumindo com a informação exatamente no caso em que ela é "não há vínculo".