invert_where nega condições de associação herdadas
O que aconteceu
non_expired era definido como expired.invert_where. invert_where nega todo predicado acumulado 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 de todo o banco de dados. 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 sua.
Causas raiz para lembrar
invert_wherenão compõe. Ele nega toda a cláusulaWHEREacumulada, incluindo condições de associação e qualquer coisa mergeada antes dele. Nunca usar num scope que pode ser encadeado numa associação ou merged; escrever o complemento com predicados explícitos (where(expires_at: nil).or(where(expires_at: Time.current..))).- Suites de teste com um único usuário escondem leaks entre usuários. Com só um usuário no banco,
NOT(user_id = X AND ...)ainda retorna as linhas daquele usuário e todo spec passa. Queries que filtram por owner precisam de pelo menos um teste com um segundo usuário afirmando que as linhas do outro usuário permanecem intocadas. update!num resultado de query confia na query. O raio de impacto de um scope errado se torna corrupção de dados no momento em que uma escrita usa o resultado. Assinatura do sintoma: o usuário reportado “nada acontece”, outros usuários misteriosamente perdem acesso.- Dica de diagnóstico: quando uma query com scope retorna ids que não pertencem ao owner do registro, printar
relation.to_sql— oNOT(...)invertido fica visível imediatamente.
Follow-ups (não cobertos por esta correção)
- Vítimas inativadas entre #679/#681 e esta correção se auto-curam no próximo login via
POST /api/v1/terapeuta/sign_in(Creation::CreateSubscriptionreativa a linha PRO existente). Usuários que nunca voltam a logar continuam rebaixados — mesma lacuna conhecida do job de revalidação recorrente ausente.