Estados — a conta (users e account_disablements)¶
Duas máquinas na mesma linha, e juntá-las já custou caro. Uma responde pode entrar?; a outra, de quem é este painel?. São independentes, e o código diz por que:
"É 'esta conta não entra mais'. Não é
revoke_person/3, que significa 'não sabemos mais qual pessoa observada é esta conta' — e o H3 mediu que aquele não remove acesso: com o elo revogado, o login por e-mail continua funcionando e as telas do tenant continuam abrindo." —lib/the_band/tenants.ex:223-227
Esse é o achado H3, parte B, de 2026-09-09. Quem desenhar uma tela ou uma consulta assumindo que uma marca implica a outra reproduz o mesmo defeito.
Máquina 1 — pode entrar?¶
Onde está o estado¶
Em dois lugares, de propósito, e escritos na mesma transação:
| Onde | O que é | Por quê |
|---|---|---|
users.disabled_at |
a resposta rápida a "pode entrar?" | é lida a cada entrada; uma junção por entrada seria caro |
account_disablements |
o episódio, com autor, razão, nota e fechamento | o par de colunas sozinho cabia um episódio, e a reativação o apagava |
"As duas escritas na MESMA transação, e é o que impede o estado inválido:
disabled_atnulo com episódio aberto, ou episódio nenhum com a conta desativada." —lib/the_band/tenants.ex:274-276
O episódio é aberto (enabled_at nulo) ou fechado — a mesma forma de ScopeGrant.vigente?/1,
e o código diz isso em voz alta (account_disablement.ex:106).
A máquina¶
stateDiagram-v2
direction LR
[*] --> ativa : create_user/2
ativa --> desativada : disable_user/4\n[razao do vocabulario declarado]\n[nao pode ser a propria conta]
desativada --> ativa : enable_user/4\n[exige episodio ABERTO]
note right of desativada
users.disabled_at preenchido
+ episodio ABERTO em account_disablements
(enabled_at nulo).
E gira o session_token: sem o giro,
"desativar" significaria "desativar
daqui a sete dias". tenants.ex:248-249
end note
note right of ativa
users.disabled_at nulo.
Reativar NAO devolve a senha —
sao decisoes diferentes. tenants.ex:325-327
end note
As quatro guardas de disable_user/4¶
Todas em lib/the_band/tenants.ex:265-271, e cada uma devolve um erro nomeado — nenhuma passa
em silêncio:
| Guarda | Erro | Por quê |
|---|---|---|
| a conta é deste tenant | :not_found |
— |
| não é a própria conta | :nao_pode_desativar_a_si |
"desativar-se a si é ficar de fora sem ter a quem pedir de volta, e num tenant com uma administração só isso tranca a organização inteira" (tenants.ex:253-256) |
| ainda está ativa | :ja_desativada |
desativar duas vezes abriria dois episódios |
| o vocabulário está carregado | :vocabulario_nao_declarado |
sem a base, "o ato recusa com {:error, :vocabulario_nao_declarado} em vez de gravar razão nenhuma" (tenants.ex:243-244) |
A última é a mais característica desta casa: as cláusulas de razão vivem em
access.account_lifecycle, na base de conhecimento, e não em constante de módulo. A razão
está escrita no leitor:
"Em constante de módulo, a lista muda num diff de template e ninguém percebe que a plataforma passou a afirmar outra coisa." —
account_lifecycle.ex:11-13"Sem a regra declarada, as listas voltam vazias e o ato de desativar recusa. (…) A alternativa — uma lista de reserva no código — é a duplicata silenciosa que a FR-069 proíbe, e faria a plataforma continuar afirmando com a base fora do ar." —
account_lifecycle.ex:18-21
A guarda de enable_user/4, e o estado que ela denuncia¶
Reativar exige episódio aberto (:sem_episodio_aberto). Não é zelo: é o detector de um
estado inválido.
"uma conta com
disabled_ate sem episódio aberto é o estado que a (…)" —lib/the_band/tenants.ex:366-367
Uma transição que a casa recusa, e por isso não é seta¶
reativar_changeset/1 já fez disabled_at: nil, disabled_by_user_id: nil — e isso foi
corrigido:
"um
deleteescrito comoupdate: depois dele, ninguém desativou aquela conta nunca." —lib/the_band/tenants.ex:312-313
Hoje a reativação fecha o episódio com nome, instante e razão, "e a abertura fica
exatamente como estava". O par de colunas em users volta a nulo; a história fica no episódio.
O equívoco, e por que ele não apaga¶
disabled_by_mistake é uma razão de reativação que marca o episódio como equívoco: ele
"deixa de contar como desligamento e continua visível, na forma de
TeamMembership.invalidated_at. Equívoco é dito, não removido." (tenants.ex:320-322).
É o mesmo gesto do vínculo de equipe — e reconhecê-lo em dois lugares diferentes é reconhecer o princípio.
Máquina 2 — o elo entre a conta e a pessoa observada¶
users.person_id + person_declared_at + person_declared_by_user_id +
person_revoked_at + person_revoked_by_user_id.
É a décima instância da declaração revogável, com a diferença de
não ser tabela própria: são cinco colunas dentro de users.
stateDiagram-v2
direction LR
[*] --> nao_declarado
nao_declarado --> vigente : declare_person/4
vigente --> revogado : revoke_person/3
revogado --> vigente : declare_person/4 de novo
vigente --> vigente : declarar OUTRA pessoa\n(revoga e declara na mesma transacao)
note right of revogado
person_id FICA. E person_revoked_at
que o tira de circulacao:
"desde quando essa conta via esse painel"
so tem resposta se o encerramento
preservar o comeco. tenants.ex:201-203
end note
Por que isto é ato de acesso, e não campo de cadastro¶
"O elo concede visibilidade. Apontar a própria conta para outra pessoa observada é passar a ver o painel dela. Não é campo de cadastro: é ato de acesso." —
lib/the_band/tenants.ex:139-141
E a substituição é transacional pela mesma razão das declarações da 066:
"Sem isso, uma falha entre as duas deixaria a conta sem elo algum, e ela perderia o próprio painel sem nada ter sido pedido." —
lib/the_band/tenants.ex:144-146
A armadilha documentada no código¶
Vale estar num modelo porque ela reaparece em qualquer código que faça revogar-e-declarar:
"Recarregar entre revogar e declarar não é zelo:
revogar_elo/3escreve porupdate_all, e a struct em memória fica velha. Um campo cujo valor novo é igual ao da struct velha NÃO entra nas mudanças do changeset — enquanto o banco já o mudou. É assim queperson_declared_by_user_idficaria nulo comperson_idpreenchido, violando a CHECK que existe justamente para impedir isso." —lib/the_band/tenants.ex:180-185
A CHECK que segura isso é users.elo_da_pessoa_tem_autor_e_data — citada em
classes/tenants-e-acesso.md.
As duas máquinas juntas, e o que a combinação significa¶
Porque são independentes, uma conta está em uma das quatro situações — e a tela precisa saber distinguir:
disabled_at |
elo | O que é verdade |
|---|---|---|
| nulo | vigente | entra, e vê o próprio painel |
| nulo | revogado ou nunca declarado | entra, e não vê painel de pessoa nenhuma |
| preenchido | vigente | não entra; o elo continua declarado, e a história fica |
| preenchido | revogado | não entra, e não há a quem o painel pertencia |
A segunda linha é a que o achado H3 mediu, e a que mais engana: elo revogado não fecha
porta. Quem quer fechar a porta usa disable_user/4.
Onde cada transição acontece, e o que a prova¶
| Transição | Função | Teste |
|---|---|---|
| ativa → desativada | tenants.ex:265 |
test/the_band/tenants/conta_desativada_test.exs |
| desativada → ativa | tenants.ex:333 |
test/the_band/tenants/conta_desativada_test.exs |
| elo: não declarado → vigente | tenants.ex:150 |
test/the_band/tenants/elo_da_conta_test.exs |
| elo: vigente → revogado | tenants.ex:207 |
test/the_band/tenants/elo_da_conta_test.exs |
Declaração de cobertura: os arquivos de teste existem e cobrem as quatro transições; o
mapeamento teste a teste (qual test "..." prova qual guarda) não foi feito neste
documento. As guardas nomeadas acima têm erro próprio no @spec, o que as torna verificáveis.
O que este modelo não mostra¶
- A sessão.
session_token,logged_in_at,failed_attempts,last_failed_atepassword_sourceformam um terceiro eixo, sobre autenticação, e não sobre a conta. Os campos estão emclasses/tenants-e-acesso.md. - O escopo de acesso.
access_scope_grantsé a nona da declaração revogável, e está lá. - O vocabulário de razões. As cláusulas de desativação e reativação vivem em
priv/knowledge_base, e mudam sem tocar em código — por isso não há lista aqui, que envelheceria em silêncio.AccountLifecycle.razoes_de_desativacao/0é a leitura.