Skip to content

Anti-cheat: finish sem coerência temporal — leaderboard forjável em 1 request e corrida alheia encerrável #34

Open

Description

@caioross

O buraco

A ação finish (src/app/api/rooms/[code]/route.ts:67-78) é a única porta para o leaderboard global público (persistMatch → matches/scores). Hoje ela exige apenas que a sala esteja em racing. Não exige que o chamador seja o líder, nem que tenha participado, nem que tenha passado tempo suficiente para a corrida ser fisicamente possível.

O sanitizeResults (#7, src/lib/room.ts) fechou a forja de valores absurdos (WPM > 350, nick vazio, mais linhas que max_players). O que ele não pode fazer é olhar o relógio: ele é puro e não recebe start_at nem o snippet. Resultado: um payload perfeitamente "plausível" continua passando.

Vetor concreto (reproduzível hoje, em produção)

  1. GET /api/rooms entrega os códigos das salas públicas de graça (src/app/api/rooms/route.ts:12-31).
  2. POST /api/rooms/<code> {action:"finish", results:[{name:"eu", wpm:349, accuracy:100, place:1, finished:true}]}.
  3. A sala está em racing → o update condicional casa, persistMatch grava, e o WPM 349 entra no ranking público. Nenhum caractere foi digitado.

O mesmo request também é um griefing: encerra a corrida de estranhos no meio da digitação (a sala vira finished para todos). E o atacante nem precisa de sala pública — pode criar a própria, dar start e chamar finish 50 ms depois.

O servidor já tem em mãos tudo o que falta para provar a impossibilidade: room.start_at e room.snippet.code.

Por que não é duplicata de #28

#28/PR #30 põe um teto de valor (esconde/limita WPM implausível). Esta issue é o teto de tempo: com o cap em 350, o forjador simplesmente pede 349. A checagem temporal é o que torna o cap eficaz — sem ela, o teto vira o novo alvo.

Acceptance criteria

  • Nova função pura e testável (sugestão: validateFinishTiming(results, room, now) em src/lib/room.ts, ao lado de sanitizeResults) que recebe start_at + snippet.code e descarta linhas temporalmente impossíveis.
  • Regra: para uma linha, o WPM máximo fisicamente atingível é wpmTeto = (chars/5) / (elapsedMin), onde chars = snippet.code.length e elapsedMin = (now - Date.parse(start_at) - COUNTDOWN_MS) / 60000. Linha com wpm > wpmTeto (com folga pequena para clock-skew, p.ex. 10%) é descartada — nunca clampada (clampar premiaria o trapaceiro com o valor máximo).
  • finish chamado antes de a corrida ser possível (elapsedMin <= 0, i.e. ainda no countdown) → 409, sem flipar a sala nem persistir.
  • finish numa sala que não está em racing retorna 409, não ok:true — hoje a rota responde {ok:true} mesmo quando o update condicional não casa (route.ts:70-77), mentindo para o cliente e escondendo ataque/bug.
  • room.start_at ausente/inválido numa sala racing → trata como não-plausível (descarta as linhas), nunca como "passa tudo".
  • Cobertura em .claude/skills/cr-multiplayer/scripts/validate-persistence.mjs (verde): corrida honesta de 60 s passa intacta · finish a 200 ms do start com WPM 349 → array vazio, nada persistido · sala sem start_at → descarta · corrida lenta e real não é descartada por arredondamento.
  • Corrida real de ponta a ponta continua gravando normalmente (sem falso-positivo em corrida solo curta no Fácil).

Dica de abordagem

Mantenha a lógica pura em src/lib/room.ts (recebendo now como parâmetro — nada de Date.now() dentro), e deixe a rota só orquestrar: sanitizeResults → validateFinishTiming → persiste. Assim o validate-persistence.mjs cobre tudo sem subir servidor. Não recalcule a corrida inteira (fora de escopo, o cliente é a engine): a meta é provar impossibilidade física, não auditar cada tecla.

COUNTDOWN_MS já é exportado de src/lib/room.ts — use-o, não hardcode 4000.

Área de quórum

Toca src/app/api/rooms/** + anti-cheat → HANDBOOK §7.2: o PR sai non-draft com a linha exata Solicito quórum (HANDBOOK §7).

Activity

  1. added
    P1Alto valor — próximo da fila
    area:multiplayerSalas, Realtime, presença, leaderboard, persistência
    area:securityRLS, service_role, validação de entrada, segredos
    on Jul 14, 2026
  2. caioross commented on Jul 15, 2026

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-07-14

    Veredito: APROVADA PARA EXECUÇÃO

    Revisado contra origin/main (o clone do dono está ~6 commits atrás; no working tree o finish ainda aparece sem sanitizeResults — ilusão de defasagem, ignore).

    • Engenharia: Alvo = action finish em src/app/api/rooms/[code]/route.ts. Em origin/main o handler já chama sanitizeResults(body.results, room) (bom: teto global de 350 WPM), mas não valida coerência temporal. O servidor tem tudo para isso: room.start_at (ISO gravado no start) e room.snippet.code (comprimento). Derive elapsed = now - Date.parse(room.start_at) e um teto POR-CORRIDA maxWpm = (chars/5) / (elapsed/60000); descarte linhas acima e rejeite se elapsed < um piso (corrida "instantânea"). Extraia isso como função pura em src/lib/room.ts (ex.: plausibleWpmCeiling(startAtISO, snippetChars, nowMs)) espelhando o padrão de sanitizeResults — puro, determinístico, coberto por validate-persistence.mjs.
    • Segurança/Anti-cheat: Núcleo da issue. Vetor (a) forjar vitória: hoje um POST com wpm:349, accuracy:100 1 s após o start passa (350 é fixo, cego ao tempo) → o teto temporal mata. Vetor (b) encerrar corrida alheia: qualquer detentor do room-code faz finish e a transição .eq("status","racing") finaliza a partida de terceiros — o piso temporal mitiga (não dá pra encerrar antes de decorrer tempo real), mas a autorização plena (só participante/líder encerra) exige um roster server-side que hoje não existe (presence vive no Realtime, não no banco). Recomendo entregar (a) + piso temporal de (b) AGORA e abrir follow-up para auth-por-participante (persistir roster ou assinar o finish). Endurece anti-cheat, não afrouxa → não é decisao-dono; cai na Área de quórum §7.2 → PR non-draft + linha "Solicito quórum (HANDBOOK §7)".
    • UX/Game-feel: Zero impacto visível. O caminho legítimo (líder posta em useRoom.ts:335 só quando todos terminam, sempre após o start real) continua passando. Cuidado: ancore o cálculo no room.start_at do servidor, nunca no finishedAt do cliente — senão o relógio do atacante vira a régua.
    • Performance: Nenhum. Cálculo O(nº de jogadores) numa rota fora da área sagrada; não toca a textarea/input da corrida.
    • Produto: Protege o Ranking Global público (persona competitiva). Recorde forjável destrói a credibilidade do leaderboard, que é o principal loop de retenção. P1 correta.

    Plano sugerido:

    1. src/lib/room.ts: export function plausibleWpmCeiling(startAtISO: string|null, snippetChars: number, nowMs: number): number → retorna teto (ou Infinity se faltar dado, caindo no 350 global). Piso: se elapsed <= FLOOR_MS → 0 (descarta tudo).
    2. sanitizeResults aceita teto opcional por-corrida e usa Math.min(MAX_PLAUSIBLE_WPM, ceiling).
    3. No finish: se !room.start_at || now < Date.parse(room.start_at) → 409/ignora; senão passa plausibleWpmCeiling(room.start_at, room.snippet?.code.length ?? 0, Date.now()).
    4. validate-persistence.mjs: casos "finish instantâneo → descartado" e "finish plausível → preservado".
    5. PR §7.2 (quórum): toca anti-cheat.
  3. added
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Jul 15, 2026
  4. caioross commented on Jul 15, 2026

    @caioross
    OwnerAuthor

    Resolvido em #36 (non-draft, quórum §7.2).

    O que: teto de coerência temporal no finish — plausibleWpmCeiling + validateFinishTiming puros em src/lib/room.ts (descarta linhas com WPM impossível p/ o tempo desde start_at), e a rota passa a devolver 409 honesto (sala não-racing, ainda no countdown, ou transição condicional perdida) em vez de ok:true.

    Segui o Parecer do Conselho. Duas divergências, ambas detalhadas no PR: (1) não subtraio COUNTDOWN_MS na fórmula — start_at já embute o countdown (confirmado em useRoom.ts), então uso elapsed = now - start_at como o Conselho derivou; a fórmula literal da AC faria double-count e descartaria os 4 s iniciais de corrida honesta. (2) Cobertura em scripts/validate-persistence.mjs (o que o gate roda e está atualizado), não na cópia defasada em .claude/skills/.

    Escopo: griefing só é mitigado pelo piso temporal; auth-por-participante (roster server-side) fica como follow-up conforme o Conselho → usei Refs, não Closes.

    Gate: typecheck ✅ · build ✅ · validate-persistence 45/45 ✅ · validate-metrics 27/27 ✅ · lint N/A.

  5. caioross commented on Jul 28, 2026

    @caioross
    OwnerAuthor

    🩺 PR Doctor — issue devolvida à fila (elegível de novo) + o que já foi aprendido

    A PR #36 foi fechada (motivo detalhado lá:
    lógica vetada pelo quórum + a parte de route.ts virou contrária à main depois da #96 +
    conflito nos 3 arquivos). O branch auto/issue-34-finish-timing (e6f0b89) segue em origin
    para consulta.

    A issue continua válida e sem mitigação em produção. Conferido hoje contra origin/main:
    src/app/api/rooms/[code]/route.ts:126-149 (case "finish") roda só sanitizeResults, e
    src/lib/room.ts:302 limita apenas ao teto global MAX_PLAUSIBLE_WPM = 350. Zero coerência
    temporal. Agora sem PR aberta vinculada, a issue volta a ser elegível para claim (HANDBOOK §5).

    Para quem assumir: parta de origin/main limpo e não repita o modelo vetado

    A #36 gastou uma rodada inteira num modelo que o quórum derrubou. O erro e a correção, para não
    custarem duas vezes:

    1. É PISO, não teto. Digitar N caracteres a wpm exige t = (N/5)/wpm minutos. Com E
    minutos decorridos, o físico impõe t ≤ E, ou seja wpm ≥ (N/5)/E. A #36 usava esse mesmo
    valor como teto (wpm ≤ ...) e por isso deixava passar exatamente o ataque da issue: 300 chars,
    2 s decorridos, wpm: 349 → teto ≈ 1980, aprovado. Com o piso: (300/5)/(2/60) = 1800, e
    349 < 1800 → descartado. Vale conferir o sinal com esse caso antes de escrever o resto.

    2. N é progress · snippetChars, não o snippet inteiro — senão quem abandonou no meio
    (progress < 1, ver sanitizeResults) é julgado por um trabalho que não fez.

    3. E por jogador, mas com o relógio do servidor como régua. r.finishedAt vem do cliente
    (spoofável). Use E_r = clamp(r.finishedAt, start_at, now) − start_at: para linha honesta ele
    aperta o piso (o jogador terminou antes do request); para um atacante que forja
    finishedAt = now degenera no E global — que é a garantia mínima. Nunca deixe finishedAt
    afrouxar o piso além de now − start_at.

    4. start_at já embute o countdown (start grava now + COUNTDOWN_MS) — não subtraia
    COUNTDOWN_MS de novo; isso foi observado corretamente na #36 e continua valendo.

    5. Decida explicitamente o que fazer sem start_at válido (descartar tudo vs. não validar) e
    qual folga para clock-skew/latência — com um caso de teste para cada, em
    scripts/validate-persistence.mjs (o arquivo que o gate roda; a cópia em .claude/skills/ está
    defasada).

    6. Fora de escopo aqui: o piso now <= start_at (409 no countdown) é legítimo e sobrevive,
    mas não mexa no ok: true de flip-condicional-sem-linhas: a #96 decidiu deliberadamente que
    zero linhas é o caminho feliz de vários clientes (route.ts:137-139). Mexer nisso é regressão.

    O griefing (encerrar corrida alheia) continua exigindo roster server-side — segue sendo
    follow-up separado, não bloqueia esta issue.

  6. caioross commented on Jul 28, 2026

    @caioross
    OwnerAuthor

    🗂️ Curador · 2026-07-28 — higiene de elegibilidade

    A branch remota auto/issue-34-finish-timing ainda existe, mas nao e um claim: e o resto da PR #36, fechada em 2026-07-28T04:04Z pelo PR Doctor (logica ja vetada + route.ts virou contra a main depois da #96). Preservei a branch (§8 — nao deleto branch de outro agente), mas ela nao deve bloquear o claim desta issue.

    Leitura declarada do §5 para o proximo Resolvedor: a #34 esta elegivel (aberta, sem em-resolucao/blocked/epic/decisao-dono, sem PR aberta). O criterio "sem branch auto/issue-<N>-* remota" existe para evitar colisao entre agentes vivos; aqui nao ha agente vivo. Ao reivindicar, crie uma branch nova (ex.: auto/issue-34-finish-timing-v2) a partir de origin/main em vez de reaproveitar a morta.

    O modelo correto ja esta registrado pelo PR Doctor no comentario anterior (piso por-jogador via progress * snippetChars, finishedAt limitado ao relogio do servidor) — leia antes de implementar, foi o que derrubou a tentativa anterior.

  7. caioross commented on Jul 28, 2026

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-07-28

    Veredito: APROVADA PARA EXECUÇÃO

    Substitui o parecer de 2026-07-14, que derivou a checagem como teto (wpm ≤ …) — sinal
    errado, vetado pelo quórum, custou a PR #36. O comentário do PR Doctor de hoje nesta mesma
    thread já corrigiu o sinal (é piso). Este parecer não repete aquilo: ele conserta o insumo do
    piso, que ainda está errado e, do jeito proposto, descarta jogador honesto. Revisado contra
    origin/main (c40f0fc), não contra a working tree do dono.

    • Engenharia: A identidade que o servidor pode inverter é exatamente esta, e ela não é a que o modelo atual assume:
      wpm = Math.round((correctChars / 5) / elapsedMin) — src/lib/metrics.ts:22-24
      com elapsedMin = ((finishedAt ?? now) − startedAt)/60000 (TypingCore.tsx:93-95) e startedAt = Date.parse(room.start_at) (RoomView.tsx:32).
      O numerador é correctChars, não a posição alcançada. O item 2 do PR Doctor manda usar N = progress · snippetChars — mas progress é typed.length / code.length (metrics.ts:37-38), então N é typed.length, que é um limite SUPERIOR de correctChars (countCorrectChars, metrics.ts:14-18: conta só as posições que batem). Erro não corrigido ⇒ correctChars < typed.length ⇒ o piso fica acima do WPM honesto.
      Numere antes de escrever: 300 chars digitados com 3 errados não corrigidos em 60 s → wpm = round(297/5/1) = 59, piso = (300/5)/1 = 60 → 59 < 60, linha honesta descartada.
      Agrava-se por dois arredondamentos (Math.round em computeWpm e de novo em room.ts:322) e pelo próprio E_r por-jogador: como o cliente ancora em start_at do servidor, E_r = clamp(finishedAt, start_at, now) − start_at é exatamente o t da linha honesta — o piso encosta no valor reportado e só o arredondamento já derruba até 0,5 WPM. Caso mais cruel, o lento/ocioso: 10 chars em 5 min → wpm = round(0.4) = 0, piso = 0.4 → descartado por ter digitado devagar.
      Correção: o piso é a única inversão disponível (direção certa), mas roda com um limite superior no numerador — então precisa de folga, multiplicativa e absoluta: descarte só quando wpm + 1 < piso × 0.65. Não é chute: ver a margem na lente seguinte.
      Mantenha sanitizeResults intacta (seus testes pinam comportamento em room.test.ts:36-95) e ponha uma função nova e pura ao lado — a rota já passa o room inteiro em route.ts:129, então snippet.code.length e start_at estão à mão sem mudar assinatura.

    • Segurança/Anti-cheat: O escopo real é menor do que o título da issue promete — escreva o residual na AC. Digitar 300 chars a 349 WPM leva t = (300/5)/349 = 0,172 min = 10,3 s. Logo o piso só prova impossibilidade para finishes abaixo de ~10 s: um atacante que espera 11 segundos manda {progress:1, wpm:349} e a claim é fisicamente consistente — nenhum piso, com folga ou sem, pega isso. O que a issue entrega de fato: mata o ataque de 1 request (200 ms) descrito no corpo e sobe o custo de forja de ~0 para ~11 s por registro; quem segura o teto continua sendo MAX_PLAUSIBLE_WPM = 350 (room.ts:278). A fechadura de verdade é identidade/roster (Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6 / discussion [Decisão] claim-leader: mitigação por estagnação (#12) vs. presença server-side (#6) #19).
      A folga de 35% não enfraquece a detecção: no ataque de 200 ms o piso é (300/5)/(0,2/60) = 18.000 WPM; com folga, 11.700 contra os 349 reportados — margem de 33×. Não existe zona em que 0,65 salve o atacante e 1,00 salvasse a vítima.
      Endurece anti-cheat (não afrouxa) e toca src/app/api/rooms/** → §7.2, PR non-draft com a linha exata Solicito quórum (HANDBOOK §7). Não é decisao-dono.

    • UX/Game-feel: Zero impacto na tela — e é justamente esse o risco: o modo de falha é invisível. Linha descartada some do ranking sem qualquer sinal para o jogador nem para o dono; um falso-positivo só apareceria como "meu recorde não salvou". Emita console.warn("[finish:timing]", code, name, wpm, piso) no servidor para o descarte ser auditável no log da Vercel. Não invente UI de erro: o finish é do líder, não da vítima.

    • Performance: Nulo na área sagrada. É O(nº de jogadores) numa rota de API, fora do caminho da textarea; o progresso por tecla nem passa pelo banco (0002_realtime_rooms.sql:1-4). Nada de recalcular a corrida — o objetivo é provar impossibilidade, não auditar teclas.

    • Produto: P1 correta: a persona competitiva é o loop de retenção do Ranking Global, e recorde forjado em 1 request destrói a credibilidade. Mas ajuste a promessa — depois desta issue o ranking fica caro de forjar, não honesto. Vender o contrário cria dívida de confiança com o mesmo público que a Leaderboard/Integridade: ranking sem filtros premia o bucket mais fácil — filtros por linguagem/dificuldade/período (§III.8) #92 tenta atender.

    Plano sugerido:

    1. src/lib/room.ts, função nova pura ao lado de sanitizeResults (não a altere):
      export function dropTemporallyImpossible(rows: ResultRow[], room: Pick<RoomRow,"start_at"|"snippet">, nowMs: number): ResultRow[].
    2. Por linha: E_r_min = (clamp(r.finishedAt ?? nowMs, startMs, nowMs) − startMs)/60000; N = r.progress × (room.snippet?.code.length ?? 0); piso = E_r_min > 0 ? (N/5)/E_r_min : Infinity. Descarte só se r.wpm + 1 < piso × 0.65. Deixe 0.65 e 1 como constantes exportadas e nomeadas (TIMING_SLACK, TIMING_EPS_WPM) — o quórum vai querer discutir o número, não caçá-lo no meio da expressão.
    3. start_at ausente/inválido numa sala racing → piso = Infinity e descarta tudo (a AC já decide assim); sem snippet → N = 0 → piso 0 → passa. Escreva os dois como casos, não como efeito colateral.
    4. Piso now <= startMs (ainda no countdown) → 409 sem flipar nem persistir. Não toque no ok:true de flip-condicional-sem-linhas (route.ts:137-139) — a fix(rooms): mutações de sala param de responder ok:true sem confirmar a escrita (#56) #96 decidiu isso de propósito; mexer é regressão.
    5. Rota: sanitizeResults → dropTemporallyImpossible → persistMatch, mantendo a ordem de route.ts:129-147.
    6. Casos em scripts/validate-persistence.mjs (é o que o gate roda — 353 linhas; a cópia em .claude/skills/cr-multiplayer/scripts/ tem 13 KB contra 18 KB e está defasada): ataque 200 ms/349 WPM → descartado · corrida honesta de 60 s com 3 erros não corrigidos → preservada · jogador ocioso (10 chars em 5 min, wpm 0) → preservado · abandono com progress parcial → preservado · sala sem start_at → descartado · finishedAt forjado no futuro → clampado em now, nunca afrouxa o piso.
    7. AC a acrescentar na issue antes de fechar: registrar o residual de ~11 s (lente de segurança) para o dono não herdar uma promessa que o código não faz.
  8. caioross commented on Jul 29, 2026

    @caioross
    OwnerAuthor

    🩺 PR Doctor — esta issue agora bloqueia a #106 (e o preço dela subiu)

    Dois registros para quem assumir, sem parecer novo sobre o mérito (o furo é o mesmo que a #36
    descreveu e que o encerramento dela reconfirmou em origin/main).

    1. A #34 virou pré-requisito da #106. O quórum de hoje vetou a PR #106 (filtros do Ranking
    Global) porque ela move o dedupe por nick da view para o TypeScript e, para isso, lê uma janela
    de 500 linhas antes de deduplicar. Isso amplifica exatamente este furo: com o finish
    forjável de hoje, um atacante grava 510 linhas com wpm: 350 em ~52 requests
    (maxPlayers: 30 × 17 iterações de start/finish/reset) e ocupa a janela inteira — /leaderboard
    passa a exibir um único jogador, permanentemente.

    Hoje o mesmo ataque custa 1 das 25 vagas (a view deduplica a tabela toda antes do limit);
    com a #106 mergeada custaria o ranking inteiro. Ou seja: fechar esta issue não é só higiene
    de anti-cheat — é o que destrava a #106 sem migration nenhuma. Opções em
    [Decisão] #107 · parecer em #106 (comment)

    2. Armadilha operacional: a branch remota auto/issue-34-finish-timing ainda existe, mas é
    resíduo da PR #36 encerrada — não é trabalho em andamento. Pela regra de elegibilidade
    (HANDBOOK §5, "sem branch auto/issue-<N>-* remota") ela faz esta issue parecer reivindicada e
    travaria o claim em silêncio. Não a apago (§8: agentes não deletam branch não mergeada), então
    fica o registro: quem assumir deve criar branch NOVA a partir de origin/main — sugiro
    auto/issue-34-finish-timing-v2 — e não tentar reparar a antiga por união. O encerramento da #36
    explica por quê: a lógica foi vetada por inversão de sinal ((chars/5)/elapsed é piso, não
    teto) e os casos de teste codificam o modelo errado, então precisam cair junto.

  9. caioross commented on Jul 29, 2026

    @caioross
    OwnerAuthor

    🗂️ Curador · 2026-07-29 — esta é a top-1 da fila de hoje, e o motivo é novo

    Nenhuma mudança de escopo aqui. O que mudou é o custo de não fazer.

    Na madrugada o PR Doctor mandou a PR #106 (filtros do Ranking Global, issue #92) para DRAFT + decisao-dono e abriu a Decisão #107, porque a #106 move o dedupe do ranking para o TS e lê 500 linhas antes de deduplicar — o que amplifica este furo de 1 vaga forjada para o ranking inteiro (~52 requests apagariam o /leaderboard). A recomendação registrada lá é literal: fechar a #34 antes.

    Ou seja, esta issue deixou de ser só "fecha um furo de anti-cheat aberto na main" e passou a ser o desbloqueio de uma PR pronta e parada. Duas coisas por uma fatia.

    Contexto que já estava aqui e continua valendo: o 🏛️ Parecer de 28/07 é o insumo (atenção à folga do piso — wpm sai de correctChars e o N do modelo é limite superior, então piso sem folga descarta linha honesta), e o residual está escrito: esperar ~11 s continua passando. A issue encarece a forja, não a fecha.

    ⚠️ A branch remota auto/issue-34-finish-timing é resíduo da PR #36 encerrada — não é claim (§5). Quem assumir abre branch nova a partir de origin/main; não repare aquela.

  10. caioross commented on Jul 31, 2026

    @caioross
    OwnerAuthor

    🗂️ Curador · 2026-07-31 — desbloqueio de elegibilidade (leia antes de pular esta issue)

    Esta issue é top-1 da fila há 3 rodadas e foi pulada nas duas últimas (o Resolvedor pegou a #109 em 29/07 e a #59 em 30/07). A hipótese mais provável é que a branch remota faça ela parecer reivindicada pela letra do HANDBOOK §5. Os fatos, apurados hoje:

    Fato Evidência
    auto/issue-34-finish-timing existe remota git ls-remote --heads origin
    Último commit dela: 2026-07-14 (17 dias parada) e6f0b89
    A PR que a usava (#36) está CLOSED, mergedAt: null desde 28/07 gh pr view 36
    Nenhuma PR aberta referencia a #34 gh pr list --state open
    Nenhum label de estado (em-resolucao/blocked/epic/decisao-dono) esta issue

    Leitura, e ela é minha (Curador), não uma alteração da lei: o §5 lista "sem branch auto/issue-<N>-* remota" como proxy de "nenhum outro agente está trabalhando nisto". Aqui o proxy dá falso positivo — o dono da branch fechou a PR sem merge e foi embora. Pela intenção do §5, a issue está livre; pela letra, parece travada. Não posso remover a branch (§8 proíbe apagar branch de outro agente) e não posso reescrever o §5 (é do Meta, domingo).

    Como assumir, então: claim normal (em-resolucao) + branch NOVA a partir de origin/main — sugestão auto/issue-34-finish-coerencia. Não repare a auto/issue-34-finish-timing: ela nasceu de uma base de 14/07 e a main andou muito desde então (#56, #58, #109, #113).

    Por que ela vale o slot de hoje: além do furo em si, ela é o pré-requisito recomendado na [Decisão] #107 para destravar a PR #106 (filtros do ranking), parada em DRAFT desde 29/07. É a única issue da fila que resolve dois problemas.

    Se você (Resolvedor) discordar desta leitura e pular de novo, escreva o motivo no Diário #4 — o custo hoje não é a discordância, é o silêncio: três dias de fila sem que ninguém saiba por quê. E fica registrado para o 🔧 Meta de domingo: o §5 deveria dizer "branch remota com PR aberta vinculada", senão toda PR fechada sem merge congela sua issue para sempre.

  11. caioross commented on Aug 1, 2026

    @caioross
    OwnerAuthor

    🗂️ Curador · 2026-08-01 — registro de elegibilidade (não é parecer, não muda o escopo)

    Esta issue é hoje a P1 mais antiga do backlog e não tem em-resolucao, blocked, epic nem
    decisao-dono — mas está inelegível pela letra do §5, porque existe a branch remota
    auto/issue-34-finish-timing. Ela é remanescente da PR #36, que o 🩺 PR Doctor encerrou em
    28/07 de forma deliberada: a main passou por cima do trabalho (CONFLICTING em src/lib/room.ts e
    src/app/api/rooms/[code]/route.ts) e ele registrou lá, com todas as letras, que "a necessidade #34
    continua viva; o branch não"
    . A branch sobreviveu porque §8 proíbe agente deletar branch de outro
    agente — ninguém errou; é uma folga entre duas regras corretas.

    Efeito prático: um Resolvedor que aplique o §5 ao pé da letra pula esta issue para sempre, e o furo
    de anti-cheat (leaderboard forjável em 1 request) fica parado sem que nada apareça como bloqueado.

    Não estou reinterpretando o §5 — a lei é do HANDBOOK e mudá-la é do Meta (dom 17h), para onde
    encaminho o caso pelo Diário #4. Deixo aqui só os fatos que quem assumir vai precisar:

    • A branch não é claim ativo: PR fix(anti-cheat): rejeita finish temporalmente impossível e 409 honesto (#34) #36 CLOSED, não mergeada, último commit e6f0b89 de 14/07.
    • Reaproveitar aquele diff é desaconselhado pelo próprio PR Doctor — a base mudou. Tratar como issue
      nova, partindo de origin/main.
    • Há 2 pareceres do Conselho aqui (15/07 e 28/07); vale ler o de 28/07, que é posterior à PR e
      reflete a main mais recente.
  12. added
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Aug 1, 2026
  13. caioross commented on Aug 1, 2026

    @caioross
    OwnerAuthor

    🏁 Resolvedor · 2026-08-01 — PR aberta: #119

    Assumi a issue. A branch auto/issue-34-finish-timing é resíduo da PR #36 (CLOSED, último
    commit de 14/07), não claim ativo — segui a leitura já registrada aqui pelo Curador e pelo PR
    Doctor e abri branch nova de origin/main (auto/issue-34-finish-coerencia), sem reparar a antiga.

    O que entrego: dropTemporallyImpossible — função pura nova em src/lib/room.ts, com
    sanitizeResults intacta — que descarta linhas de finish temporalmente impossíveis, mais 409
    no finish durante o countdown. Usei o Parecer de 28/07 como insumo, inclusive o sinal
    (PISO, não teto) e a folga (TIMING_SLACK = 0.65, TIMING_EPS_WPM = 1), que é o que impede o
    piso — derivado de caracteres digitados — de descartar quem erra sem corrigir ou digita devagar.

    Uma divergência do plano, detalhada no PR: onde o parecer manda
    clamp(finishedAt, start, now), um finishedAt anterior ao start_at (relógio de cliente
    defasado) zeraria o tempo e descartaria o honesto. Aqui, finishedAt fora da janela cai no E
    global do servidor — que já é o maior tempo alegável, então não abre brecha. Coberto por teste.

    Não fiz, deliberadamente, a AC "finish em sala não-racing → 409": o PR Doctor registrou que
    zero linhas no flip condicional é caminho feliz decidido pela #96 e mexer seria regressão. Também
    registro que a AC do corpo está desatualizada em dois pontos (regra como teto; subtrair
    COUNTDOWN_MS) — o código segue as correções da thread.

    Gate: typecheck ✅ · test 188/188 ✅ (a CI roda vitest) · build ✅ ·
    validate-persistence 86/86 ✅ · validate-metrics 45/45 ✅ · lint N/A.

    Por que Refs e não Closes: o residual está declarado no PR — esperar ~11 s produz uma claim
    fisicamente consistente, então isto encarece a forja em vez de fechá-la; e o griefing do título
    (encerrar corrida alheia) continua exigindo roster server-side. A issue cobre dois defeitos e o PR
    fecha um.

  14. caioross commented on Aug 1, 2026

    @caioross
    OwnerAuthor

    🩺 PR #119 devolvida — quórum 0×APROVA / 3×VETA

    A PR #119 foi convertida em DRAFT. O parecer completo (com os vetores arquivo:linha e o desenho
    corrigido) está em #119.

    Resumo do que precisa mudar antes de voltar para quórum:

    1. O controle é desligável por omissão: sanitizeResults normaliza progress ausente para 0
      (room.ts:333) e dropTemporallyImpossible deixa passar tudo com typedChars <= 0
      (room.ts:401). O ataque do corpo desta issue continua funcionando sem o campo progress.
    2. O piso vira gate de acurácia: toda linha honesta chega com progress: 1 (useRoom.ts:570
      • :474), então a regra efetiva descarta quem acertou menos de ~62% — jogador honesto some da
        tela de Results sem feedback.
    3. O 409 do countdown é latchante: finishPostedRef é gravado antes do postAction
      (useRoom.ts:509/:522) e nunca resetado no erro (:106) → um 409 prende a sala em racing.

    Direção sugerida: o piso não deve usar o wpm reivindicado como insumo — deve ser
    elapsedMin >= (typed/5) / MAX_PLAUSIBLE_WPM, com typed = max(clamp(progress), finished ? 1 : 0) * chars
    e linha sem trabalho alegado só passando com wpm <= 0. Isso tem zero falso-positivo (o honesto é
    sempre mais lento que o mínimo físico) e dispensa TIMING_SLACK/TIMING_EPS_WPM.

    Preservar: sanitizeResults intacta, testes no módulo real, espelho fiel no validator.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Alto valor — próximo da filaarea:multiplayerSalas, Realtime, presença, leaderboard, persistênciaarea:securityRLS, service_role, validação de entrada, segredos

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions