invert_where inverteu as condições da associação
O que aconteceu
Subscription.non_expired era definido como expired.invert_where. O invert_where nega todos os predicados acumulados na relation, não só os que o scope atual adicionou. Encadeado numa associação (user.subscriptions.non_expired), o user_id = X implícito também foi negado:
sql
WHERE NOT (user_id = X AND expires_at IS NOT NULL AND expires_at < now)
Isso casa com todas as linhas de outros usuários mais as linhas não expiradas do próprio usuário. Combinado com default_scope { order(start_at: :desc, ...) }, o find_by em Subscriptions::Pro::ExpireWhenIneligible retornava a assinatura PRO ativa mais recente do banco inteiro. Todo webhook de expiração do CITRG e todo login inelegível inativava a assinatura PRO de um usuário inocente, enquanto o usuário pretendido mantinha a dele.
Causa raiz
invert_where não compõe. Ele nega a cláusula WHERE acumulada inteira, incluindo condições de associação e qualquer coisa mesclada antes dele. Nunca use em um scope que possa ser encadeado numa associação ou mesclado; escreva o complemento com predicados explícitos:
ruby
scope :non_expired, -> { where(expires_at: nil).or(where(expires_at: Time.current..)) }
Correção
O scope foi reescrito sem invert_where e o ExpireWhenIneligible passou a reativar regular-terapeuta também quando não há PRO ativo para expirar (caso limbo). Detalhes em 20260717112846_fix_non_expired_scope_invert_where.
Como evitar
- Nunca use
invert_whereem scope que possa compor. Escreva o complemento com predicados explícitos. - Suítes com um único usuário escondem vazamento entre usuários. Com só um usuário no banco,
NOT(user_id = X AND ...)ainda retorna as linhas dele e todo spec passa. Query que filtra por dono precisa de pelo menos um teste com um segundo usuário afirmando que as linhas do outro não foram tocadas. update!sobre o resultado de uma query confia na query. O raio de alcance de um scope errado vira corrupção de dado no instante em que uma escrita usa o resultado. Assinatura do sintoma: o usuário reportado diz que “não acontece nada”, outros usuários perdem acesso misteriosamente.- Dica de diagnóstico: quando uma query com escopo retorna ids que não pertencem ao dono do registro, imprima
relation.to_sql— oNOT(...)invertido aparece na hora.
Follow-ups
- Vítimas inativadas entre os PRs #679/#681 e esta correção se recuperam sozinhas no próximo login via
POST /api/v1/terapeuta/sign_in(oCreation::CreateSubscriptionreativa a linha PRO existente). Quem nunca mais logar continua rebaixado — a mesma lacuna conhecida do job de revalidação recorrente ausente, registrada em subscriptions_pro_access_never_expired.