## Resumo
`DELETE /u/.json` tem sucesso e a linha da tabela `users` é removida, mas várias tabelas mantêm linhas cujo `user_id` ainda contém o id do usuário destruído. Nenhuma delas possui chave estrangeira, associação `dependent:`, job de limpeza ou motivo documentado para retenção.
Uma delas é uma falha direta da própria intenção declarada do destruidor: `UserDestroyer#delete_posts` foi escrito para definir `topics.user_id` como nulo, mas itera sobre `user.posts`, cujo escopo padrão exclui posts descartados — assim, um tópico cujo primeiro post já foi excluído continua apontando para o usuário destruído.
Duas outras, `incoming_links` e `search_logs`, são tabelas que o próprio caminho de anonimização do Discourse trata como dados pessoais vinculados ao usuário (`Jobs::AnonymizeUser#anonymize_ips`), e que o `UserMerger` explicitamente redireciona — mas que o `UserDestroyer` não toca de forma alguma.
Reproduzido na `v2026.7.1`, com **configurações padrão** e uma conta que não possui posts. `UserDestroyer#delete_posts` permanece inalterado no `main` até 25/09/2026.
## Versão
Discourse `v2026.7.1` (auto-hospedado, instância de teste local autorizada). Principalmente núcleo; uma linha de plugin é listada separadamente abaixo.
## Passos para reproduzir
O caso mínimo não requer alteração de configuração do site nem posts, portanto funciona com o `delete user self max post count` padrão:
1. Registre uma conta de teste normal.
2. Enquanto estiver logado como essa conta, execute uma busca: `GET /search.json?q=<marcador único>`.
3. Visite a página HTML de algum tópico enquanto estiver logado, chegando de um referer externo e com o parâmetro de compartilhamento de outra pessoa: `GET /t//<topic_id>?u=` com `Referer: https://example.invalid/x`.
4. De um navegador deslogado, visite qualquer tópico com o parâmetro de compartilhamento da **conta de teste**: `GET /t//<topic_id>?u=` com um `Referer` externo.
5. Como a conta de teste, exclua a conta: `DELETE /u/.json` com `context=/my/preferences/account`. Retorna `{“success”:“OK”}` e a linha da tabela `users` desaparece.
6. Consulte:
```sql
SELECT id, user_id, current_user_id, ip_address, post_id
FROM incoming_links WHERE user_id = <id> OR current_user_id = <id>;
SELECT id, user_id, term FROM search_logs WHERE user_id = ;
```
O `poc.py` anexado executa os passos 1–5 via HTTP e imprime o SQL exato para o passo 6.
## Esperado
Após um usuário excluir sua própria conta, as linhas que identificam esse usuário devem ser removidas, definidas como nulas ou reatribuídas — assim como o `UserDestroyer` já faz para `posts.user_id` (definido como nulo), `categories.user_id` (reatribuído ao sistema) e `topics.user_id` (pretendido a ser definido como nulo).
## Real
Tudo abaixo foi medido em uma única execução, após `DELETE /u/.json` retornar 200 e `SELECT count(*) FROM users WHERE id = ` retornar 0.
| Tabela / coluna | Linhas restantes | O que a linha contém | Observações |
|—|—|—|—|
| `incoming_links.user_id` | 1 | id do usuário excluído + `ip_address` do visitante + `post_id` | clique em link de compartilhamento atribuído ao usuário excluído |
| `incoming_links.current_user_id` | 1 | id do usuário excluído + `post_id` + referer | registro de clique do próprio usuário excluído |
| `search_logs.user_id` | 1 | id do usuário excluído + o **termo de busca** que ele digitou | mantido por `search query log max retention days`, padrão 365 |
| `topics.user_id` | 1 | id do usuário excluído | apenas para um tópico cujo primeiro post já foi descartado — veja abaixo |
| `post_revisions.user_id` | 2 | id do usuário excluído + `modifications[‘raw’]`, ou seja, seus corpos de post anteriores | |
| `custom_emojis.user_id` | 1 | id do usuário excluído | atribuição do uploader em um emoji de todo o site |
| `topic_localizations.localizer_user_id` | 1 | id do usuário excluído | |
| `policy_users.user_id` | 1 | id do usuário excluído + `accepted_at` | plugin `discourse-policy` |
Executar os jobs de limpeza fornecidos em seguida não muda nada: reli todas as linhas após `Jobs::UpdateScoresForToday`, `Jobs::CleanUpUnusedRegisteredUserApiKeyClients` e `PostDestroyer.destroy_stubs` e todas ainda estavam lá.
Para contraste, na mesma execução estas **sim** se comportaram: as linhas de `users`, `user_profiles` e `email_tokens` foram removidas, as linhas de `chat_mentions` direcionadas à conta foram excluídas, e `posts.user_id` e a maioria dos `topics.user_id` foram definidos como nulos. Portanto, esta é uma lista de falhas específicas, não uma afirmação de que a exclusão não faz nada.
Como as linhas foram criadas: os passos 1–5 acima criam as linhas de `incoming_links` e `search_logs` através de navegação comum. As linhas de `topics.user_id`, `post_revisions.user_id` e `policy_users` vêm de postagem comum, auto-exclusão de um tópico e aceitação de uma política. As linhas de `custom_emojis` e `topic_localizations` foram semeadas diretamente no fixture de teste, porque enviar um emoji personalizado e escrever uma localização de tópico são caminhos de admin/plugin, e não algo que uma conta normal faz; o comportamento de exclusão medido para elas é, de outra forma, idêntico.
### O caso de `topics.user_id` é um bug concreto de escopo (off-by-scope)
`UserDestroyer#delete_posts` está escrito como:
```ruby
user.posts.find_each do |post|
…
if post.topic && post.is_first_post?
Topic.unscoped.where(id: post.topic_id).update_all(user_id: nil)
end
end
```
`user.posts` usa o escopo padrão, que exclui posts descartados. Se o usuário já havia excluído um de seus próprios tópicos — o stub foi posteriormente descartado por `PostDestroyer.destroy_stubs` — esse post não é iterado, então a definição como nulo nunca é executada para seu tópico. Na minha execução, dois dos três tópicos do sujeito terminaram com `user_id IS NULL` e o terceiro, cujo primeiro post havia sido descartado antes da exclusão da conta, manteve `user_id = <id excluído>`.
`Post.unscoped.where(user_id: result.id).update_all(user_id: nil)` algumas linhas depois usa `unscoped`, então o post em si é corretamente desvinculado — apenas o tópico é omitido.
### Por que `incoming_links` e `search_logs` se destacam
Elas não são meramente ids órfãos. O Discourse já as classifica como dados pessoais vinculados ao usuário em outros lugares:
* `app/jobs/regular/anonymize_user.rb:43` — `IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip)`, ao lado de `SearchLog`, `TopicLinkClick`, `TopicViewItem`, `UserProfileView`.
* `app/services/user_merger.rb:348` — `IncomingLink.where(user_id: …)` e `.where(current_user_id: …)` são ambos redirecionados para o usuário de destino.
Anonimizar um usuário e mesclar um usuário tratam ambas essas tabelas. Excluir um usuário não.
## Impacto
Sem acesso não autorizado: nenhuma dessas linhas é servida a usuários anônimos ou regulares, e não estou afirmando o contrário. O impacto é a retenção de dados e a integridade referencial — após um usuário exercer a exclusão de autoatendimento, o site ainda mantém linhas vinculadas a ele, incluindo o que ele buscou e quais links seguiu, sem expiração vinculada à exclusão e sem ferramentas de operador para limpá-las.
Isso é desconfortável para qualquer site que lide com uma solicitação de apagamento, e é inconsistente com o próprio tratamento do caminho de exclusão para `posts`, `categories` e `topics`.
## Correção sugerida
1. Em `UserDestroyer#delete_posts`, itere sobre `user.posts.with_deleted` (ou defina os tópicos como nulos em uma passagem separada `Topic.unscoped.where(user_id: user.id).update_all(user_id: nil)`) para que primeiros posts já descartados não deixem seu tópico atribuído.
2. Adicione as tabelas de análise vinculadas ao usuário ao caminho de destruição da forma como `Jobs::AnonymizeUser` já as enumera: exclua ou defina como nulo `IncomingLink.where(user_id:)`, `IncomingLink.where(current_user_id:)` e `SearchLog.where(user_id:)`.
3. Defina como nulo as colunas de atribuição restantes — `post_revisions.user_id`, `custom_emojis.user_id`, `topic_localizations.localizer_user_id` — consistentemente com `posts.user_id`.
4. `policy_users` pertence ao `discourse-policy`; ele pode usar `user_destroyer_on_content_deletion_callbacks` ou adicionar `dependent: :destroy`. Estou feliz em abrir isso separadamente no repositório do plugin, se preferirem.
Uma especificação de regressão poderia afirmar que, após `UserDestroyer#destroy`, nenhuma linha neste conjunto ainda contém o id do usuário destruído.