Skip to content

fix(anti-cheat): rejeita finish temporalmente impossível e 409 honesto (#34) - #36

Closed
caioross wants to merge 1 commit into
mainfrom
auto/issue-34-finish-timing
Closed

fix(anti-cheat): rejeita finish temporalmente impossível e 409 honesto (#34)#36
caioross wants to merge 1 commit into
mainfrom
auto/issue-34-finish-timing

Conversation

@caioross

Copy link
Copy Markdown
Owner

Contexto

finish (src/app/api/rooms/[code]/route.ts) é a única porta do leaderboard global e hoje exige apenas a sala em racing. sanitizeResults (#7) fecha valores absurdos, mas é cego ao relógio: um payload plausível (WPM 349) postado logo após o start entra no ranking sem nenhuma tecla digitada — e o mesmo request encerra a corrida de terceiros.

Segui o 🏛️ Parecer do Conselho (APROVADA PARA EXECUÇÃO): entrego o teto temporal + piso de countdown agora; a auth-por-participante (roster server-side) fica para follow-up, como o Conselho recomendou.

O que mudou e por quê

  • src/lib/room.ts (puro, determinístico):
    • plausibleWpmCeiling(startAtISO, snippetChars, now) → teto físico por-corrida (chars/5)/elapsedMin, com CLOCK_SKEW_SLACK (10%) p/ clock-skew/latência. Retorna 0 quando nada é plausível (sem start_at, inválido, ou ainda no countdown).
    • validateFinishTiming(results, room, now)descarta (não clampa) linhas acima do teto; teto 0 ⇒ descarta tudo.
  • route.ts finish: 409 se a sala não está em racing; 409 se ainda no countdown (now <= start_at, sem flipar/persistir); 409 quando a transição condicional não casa (antes respondia ok:true, mentindo p/ o cliente). Pipeline: sanitizeResultsvalidateFinishTiming → persiste.
  • scripts/validate-persistence.mjs: +12 casos cobrindo o teto, countdown, start_at/snippet ausentes, corrida honesta preservada e corrida lenta sem falso-positivo.

Divergências (transparência)

  1. Fórmula sem subtrair COUNTDOWN_MS. A AC sugeria elapsedMin = (now - start_at - COUNTDOWN_MS)/60000, mas no código real o start grava start_at = now + COUNTDOWN_MS (o countdown já está embutido — confirmado em useRoom.ts:269-333, onde o cliente conta regressivo até start_at e só posta finish quando now >= start_at). Subtrair de novo seria double-count e descartaria os primeiros 4 s de corrida honesta. Uso elapsed = now - start_at, exatamente como o Parecer do Conselho derivou. COUNTDOWN_MS continua importado e usado no start; nada hardcoded.
  2. Cobertura em scripts/validate-persistence.mjs, não em .claude/skills/cr-multiplayer/scripts/... como a AC citou. Esse é o arquivo que o gate roda (node scripts/validate-persistence.mjs, HANDBOOK §6 / CLAUDE.md) e o único atualizado (espelha o sanitizeResults de Segurança: finish aceita results não validados → leaderboard global forjável (anti-cheat) #7). A cópia em .claude/skills/ está defasada (pré-Segurança: finish aceita results não validados → leaderboard global forjável (anti-cheat) #7, espelha um sanitizeResult singular com teto 300) e não é rodada pelo gate — sincronizá-la seria refactor fora de escopo. Fica como candidato a dedup/follow-up.

Escopo / follow-up

Griefing (encerrar corrida alheia) é mitigado pelo piso temporal, não fechado: a autorização plena (só participante/líder encerra) exige um roster server-side que hoje não existe (presença vive no Realtime). Por isso uso Refs #34 e não Closes — deixo ao dono/PR Doctor decidir fechar ou abrir o follow-up de auth-por-participante.

Validação (gate real)

  • pnpm typecheck ✅ · pnpm build
  • node scripts/validate-persistence.mjs45/45 (12 novos)
  • node .claude/skills/cr-typing-engine/scripts/validate-metrics.mjs ✅ 27/27
  • pnpm lint = N/A (sem config ESLint no repo; a CI não roda lint)

Riscos

  • Falso-positivo em corrida honesta só ocorreria se o jogador digitasse a >90% do teto físico — e sanitizeResults já capa em 350 WPM global. Folga de 10% cobre clock-skew/rede em corridas realistas.
  • now ancorado no start_at do servidor, nunca no finishedAt do cliente (o relógio do atacante não é régua).

Solicito quórum (HANDBOOK §7)

Refs #34

… não ok:true (#34)

O leaderboard global só é alimentado pela action `finish`, que hoje exige
apenas a sala em `racing`. `sanitizeResults` (#7) fecha valores absurdos, mas
é cego ao relógio: um payload "plausível" (WPM 349) postado logo após o
`start` entra no ranking sem nenhuma tecla digitada.

- `plausibleWpmCeiling(startAtISO, snippetChars, now)` e `validateFinishTiming`
  (puras, em src/lib/room.ts): teto físico por-corrida `(chars/5)/elapsedMin`
  com 10% de folga p/ clock-skew; linhas acima são DESCARTADAS (não clampadas).
  `start_at` já embute o countdown (a rota grava `now + COUNTDOWN_MS`), então o
  tempo decorrido é `now - start_at` — sem subtrair COUNTDOWN_MS de novo.
- Rota `finish`: 409 se a sala não está em `racing`, 409 se ainda no countdown
  (`now <= start_at`, sem flipar/persistir), e 409 quando a transição
  condicional não casa — antes respondia `ok:true` mentindo p/ o cliente.
- `start_at`/snippet ausentes numa sala `racing` → descarta tudo (nunca "passa
  tudo"), encerrando a sala sem alimentar o leaderboard.
- Cobertura em scripts/validate-persistence.mjs (+12 casos, 45/45 verde).

Refs #34

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

@caioross is attempting to deploy a commit to the caioross' projects team on Vercel, but is not a member of this team. To resolve this issue, you can:

  • Make your repository public. Collaboration is free for open source and public repositories.
  • Upgrade to pro and add @caioross as a member. A Pro subscription is required to access Vercel's collaborative features.
    • If you're the owner of the team, click here to upgrade and add @caioross as a member.
    • If you're the user who initiated this build request, click here to request access.
    • If you're already a member of the caioross' projects team, make sure that your Vercel account is connected to your GitHub account.

To read more about collaboration on Vercel, click here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces anti-cheat temporal validation to prevent forged race results by verifying that the submitted WPM is physically plausible given the elapsed time. However, the review identified a critical security vulnerability where extremely short elapsed times allow bots to bypass the WPM ceiling, which should be resolved by validating actual typing speed against a maximum plausible limit. Additionally, a UX regression was flagged in the /finish endpoint where concurrent multiplayer finishes would incorrectly trigger 409 Conflict errors for subsequent finishers instead of returning a successful response.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/lib/room.ts
Comment on lines +208 to +217
export function validateFinishTiming(
results: ResultRow[],
room: Pick<RoomRow, "start_at" | "snippet">,
now: number
): ResultRow[] {
if (!Array.isArray(results) || results.length === 0) return [];
const ceiling = plausibleWpmCeiling(room.start_at ?? null, room.snippet?.code.length ?? 0, now);
if (ceiling <= 0) return []; // corrida impossível/sem dado → nada plausível
return results.filter(r => r.wpm <= ceiling);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

🚨 Brecha de Segurança / Bypass do Anti-Cheat

A lógica atual de validação temporal possui uma vulnerabilidade crítica que permite que trapaceiros enviem resultados instantâneos com o WPM máximo permitido (350 WPM).

O Problema:

O teto de WPM (plausibleWpmCeiling) é calculado como ((chars / 5) / elapsedMin) * CLOCK_SKEW_SLACK.
Quando o tempo decorrido (elapsedMin) é extremamente curto (por exemplo, 2 segundos), o teto calculado torna-se absurdamente alto (ex: 1980 WPM para um snippet de 300 caracteres).
Como o WPM enviado pelo cliente (r.wpm) já foi sanitizado e limitado a 350 WPM por sanitizeResults, a verificação r.wpm <= ceiling (ex: 350 <= 1980) será verdadeira. Isso permite que um bot termine a corrida em 2 segundos e seu resultado de 350 WPM seja aceito e persistido no leaderboard global!

A Solução:

Devemos validar a velocidade real de digitação do jogador com base no seu progresso e no tempo decorrido, garantindo que essa velocidade real não exceda o limite humano plausível (MAX_PLAUSIBLE_WPM * CLOCK_SKEW_SLACK). O cheater pode forjar o campo r.wpm, mas não pode forjar o tempo decorrido no servidor e o progresso (que precisa ser 1 para constar como finalizado).

export function validateFinishTiming(
  results: ResultRow[],
  room: Pick<RoomRow, "start_at" | "snippet">,
  now: number
): ResultRow[] {
  if (!Array.isArray(results) || results.length === 0) return [];
  if (!room.start_at) return [];
  const startMs = Date.parse(room.start_at);
  if (!Number.isFinite(startMs)) return [];
  const elapsedMin = (now - startMs) / 60000;
  if (elapsedMin <= 0) return [];

  const chars = Math.max(0, Math.round(Number(room.snippet?.code.length)) || 0);
  if (chars <= 0) return [];

  const ceiling = ((chars / 5) / elapsedMin) * CLOCK_SKEW_SLACK;

  return results.filter(r => {
    // 1. O WPM declarado não pode exceder o teto calculado para o tempo decorrido
    if (r.wpm > ceiling) return false;

    // 2. A velocidade real de digitação baseada no progresso não pode exceder o limite humano plausível
    const actualWpm = ((chars * (r.progress ?? 0)) / 5) / elapsedMin;
    return actualWpm <= MAX_PLAUSIBLE_WPM * CLOCK_SKEW_SLACK;
  });
}

Comment on lines +95 to +100
function validateFinishTiming(results, room, now) {
if (!Array.isArray(results) || results.length === 0) return [];
const ceiling = plausibleWpmCeiling(room.start_at ?? null, room.snippet?.code.length ?? 0, now);
if (ceiling <= 0) return [];
return results.filter(r => r.wpm <= ceiling);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

🚨 Brecha de Segurança / Bypass do Anti-Cheat (Espelho de Testes)

Esta função precisa ser atualizada para espelhar a correção de segurança sugerida em src/lib/room.ts. Sem isso, a validação de persistência nos scripts de teste não cobrirá o cenário real de bypass temporal onde um bot envia 350 WPM em poucos segundos.

function validateFinishTiming(results, room, now) {
  if (!Array.isArray(results) || results.length === 0) return [];
  if (!room.start_at) return [];
  const startMs = Date.parse(room.start_at);
  if (!Number.isFinite(startMs)) return [];
  const elapsedMin = (now - startMs) / 60000;
  if (elapsedMin <= 0) return [];

  const chars = Math.max(0, Math.round(Number(room.snippet?.code.length)) || 0);
  if (chars <= 0) return [];

  const ceiling = ((chars / 5) / elapsedMin) * CLOCK_SKEW_SLACK;

  return results.filter(r => {
    if (r.wpm > ceiling) return false;
    const actualWpm = ((chars * (r.progress ?? 0)) / 5) / elapsedMin;
    return actualWpm <= MAX_PLAUSIBLE_WPM * CLOCK_SKEW_SLACK;
  });
}

Comment on lines 73 to 104
case "finish": {
// Fronteira anti-cheat: `results` vem do cliente e alimenta o leaderboard
// global. Sanitiza/clampa/descarta linhas forjadas antes de persistir.
const results: ResultRow[] = sanitizeResults(body.results, room as RoomRow);
// global. Só a sala em `racing` pode terminar — responder ok:true fora disso
// mentiria para o cliente e esconderia ataque/bug.
if (room.status !== "racing")
return NextResponse.json({ ok: false, error: "Sala não está em corrida" }, { status: 409 });

const now = Date.now();
const startMs = room.start_at ? Date.parse(room.start_at) : NaN;
// Ainda no countdown (start_at no futuro): a corrida não pode ter terminado.
// Não flipa nem persiste. (start_at ausente/inválido cai adiante: a sala
// encerra com results vazio, sem alimentar o leaderboard.)
if (Number.isFinite(startMs) && now <= startMs)
return NextResponse.json({ ok: false, error: "Corrida ainda não começou" }, { status: 409 });

// Sanitiza valores forjados e então descarta linhas temporalmente
// impossíveis (WPM alto demais para o tempo real desde `start_at`).
const sanitized: ResultRow[] = sanitizeResults(body.results, room as RoomRow);
const results: ResultRow[] = validateFinishTiming(sanitized, room as RoomRow, now);
// Conditional transition racing→finished so only the first caller persists.
const { data: flipped } = await sb
.from("rooms")
.update({ status: "finished", results })
.eq("code", code)
.eq("status", "racing")
.select("code");
if (flipped && flipped.length) await persistMatch(sb, room as RoomRow, results);
// Perdeu a corrida da transição (outro `finish` concorrente já flipou) → 409.
if (!flipped || !flipped.length)
return NextResponse.json({ ok: false, error: "Sala não está em corrida" }, { status: 409 });
if (results.length) await persistMatch(sb, room as RoomRow, results);
return NextResponse.json({ ok: true });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ Regressão de UX: Erros 409 para Jogadores Legítimos

A alteração para retornar 409 Conflict quando a sala não está em corrida ou quando a transição condicional falha (!flipped || !flipped.length) introduz uma regressão grave na experiência do usuário.

O Problema:

Em uma corrida multiplayer, múltiplos jogadores terminam a corrida em instantes muito próximos e seus respectivos clientes enviam requisições para o endpoint /finish.

  1. O cliente do primeiro colocado envia a requisição, atualiza o status da sala para finished e retorna 200 OK.
  2. Os clientes dos demais colocados enviam suas requisições logo em seguida. Como o status da sala já foi alterado para finished pelo primeiro colocado, a verificação room.status !== "racing" ou a transição condicional falhará, fazendo com que todos os outros jogadores recebam um erro 409 ("Sala não está em corrida").
  3. Isso fará com que a interface do usuário exiba mensagens de erro falsas para jogadores honestos que simplesmente não terminaram em primeiro lugar.

A Solução:

Se a sala já estiver no status finished, o endpoint deve retornar 200 OK de forma silenciosa e bem-sucedida, pois o objetivo final (finalizar a corrida) já foi atingido. Devemos retornar 409 apenas se a sala estiver no status lobby (onde finalizar é impossível).

    case "finish": {
      // Fronteira anti-cheat: `results` vem do cliente e alimenta o leaderboard
      // global. Só a sala em `racing` pode terminar — responder ok:true fora disso
      // mentiria para o cliente e esconderia ataque/bug.
      if (room.status === "lobby")
        return NextResponse.json({ ok: false, error: "Sala não está em corrida" }, { status: 409 });
      if (room.status === "finished")
        return NextResponse.json({ ok: true });

      const now = Date.now();
      const startMs = room.start_at ? Date.parse(room.start_at) : NaN;
      // Ainda no countdown (start_at no futuro): a corrida não pode ter terminado.
      // Não flipa nem persiste. (start_at ausente/inválido cai adiante: a sala
      // encerra com results vazio, sem alimentar o leaderboard.)
      if (Number.isFinite(startMs) && now <= startMs)
        return NextResponse.json({ ok: false, error: "Corrida ainda não começou" }, { status: 409 });

      // Sanitiza valores forjados e então descarta linhas temporalmente
      // impossíveis (WPM alto demais para o tempo real desde `start_at`).
      const sanitized: ResultRow[] = sanitizeResults(body.results, room as RoomRow);
      const results: ResultRow[] = validateFinishTiming(sanitized, room as RoomRow, now);
      // Conditional transition racing→finished so only the first caller persists.
      const { data: flipped } = await sb
        .from("rooms")
        .update({ status: "finished", results })
        .eq("code", code)
        .eq("status", "racing")
        .select("code");
      // Perdeu a corrida da transição (outro `finish` concorrente já flipou) → retorna sucesso silencioso.
      if (flipped && flipped.length) {
        if (results.length) await persistMatch(sb, room as RoomRow, results);
      }
      return NextResponse.json({ ok: true });
    }

@caioross

Copy link
Copy Markdown
Owner Author

🩺 Quórum adversarial (HANDBOOK §7.2) — 1 APROVA / 2 VETO ❌ (não mergeado)

Classificação: quórum — anti-cheat + src/app/api/rooms/**. Pré-requisitos OK (CI verde, mergeable, diff lido no head e6f0b89). Rodei 3 lentes adversariais lendo o código commitado (não o working tree). Confirmei a aritmética manualmente — não é veto-falso de base defasada.

Vetos (confirmados, com vetor):

  • 🔴 Ofensiva — VETO. src/lib/room.ts:194,216 — a coerência temporal está com o sinal invertido. O código usa (chars/5)/elapsed·1.1 como teto e mantém wpm <= teto. Mas esse valor é o piso físico de um finisher — quem digitou chars num tempo t ≤ elapsed satisfaz wpm ≥ (chars/5)/elapsed, não . Vetor concreto: pós-start, start_at = T0+4000, snippet 300 chars; atacante posta finish em T0+6000 (elapsed 2 s) com wpm: 349. Fora do countdown (route.ts:85 passa), sanitizeResults passa (349 ≤ 350), e validateFinishTiming calcula teto = (300/5)/(2/60)·1.1 ≈ 1980 → mantém 349. Persistido no leaderboard. É exatamente o cheat que o PR diz fechar. 349 WPM em 300 chars exige 10,3 s de digitação; só decorreram 2 s → fisicamente impossível, mas escorre.
  • 🔴 Domínio — VETO. src/lib/room.ts:191 + route.ts:80,91 — mesmo corrigindo o sinal, o teto usa um now global (instante em que o líder posta o finish, quando todos já terminaram — useRoom.ts), enquanto o WPM de cada jogador é congelado no próprio fim (r.finishedAt - start_at, Race.tsx). Logo elapsed = tempo do jogador mais lento; qualquer finisher >~10% mais rápido que o último cairia fora do teto — o vencedor/top do leaderboard seria descartado. Um teto honesto tem de ser por-jogador via r.finishedAt, não com now único. O teste "corrida honesta" mascara o defeito: usa now−start = 60s mas afirma ana wpm 45 (que exigiria 80 s para 300 chars) como legítima — o próprio caso consagra um finish fisicamente impossível.

AppSec ✅ — service_role server-side, sem injeção PostgREST, now do servidor é a régua, transição racing→finished atômica. Correto, mas irrelevante diante da lógica invertida.

Veredito: a mudança não entrega o anti-cheat que promete e, pior, o teto invertido/global pode descartar corrida honesta em produção. O gate ficou verde só porque os testes cobrem apenas configs (snippet curto / elapsed longo) onde o forjado ultrapassa o teto — nunca a janela real de ataque.

Por que devolvo ao Resolvedor (DRAFT) em vez de reparar inline: a correção não é 1 linha — o modelo correto precisa (a) inverter para piso e (b) ser por-jogador, usando progress·snippetChars (não o snippet inteiro) e r.finishedAt para o tempo de cada linha; e reescrever os casos de teste, que hoje codificam o modelo errado. É redesenho de anti-cheat que alimenta a produção — decisão de design do Resolvedor/Conselho, não patch auto-aprovado numa rodada autônoma (e o merge estaria barrado pelo gate de produção nesta rodada de qualquer forma). Não aplico decisao-dono: não é decisão do dono, é um bug para o Resolvedor corrigir e reabrir o quórum. Refs #34 segue correto (a auth-por-participante contra griefing continua fora de escopo).

@caioross

Copy link
Copy Markdown
Owner Author

Passagem do PR Doctor (sem parecer novo — o veredito do quórum em e6f0b89 segue de pé e nada mudou nesta PR desde então).

Só um registro de estado para quem assumir: a branch agora está CONFLICTING. A main andou 11 dias exatamente sobre os dois arquivos desta PR (src/lib/room.ts e src/app/api/rooms/[code]/route.ts). Ao assumir, repare por união (git merge origin/main na branch, nunca rebase/--force) antes de mexer na lógica.

Lembrando o que ainda bloqueia, para não se perder: o teto temporal precisa virar piso e ser por-jogador (r.finishedAt, com progress·snippetChars em vez do snippet inteiro) — e os casos de teste atuais codificam o modelo errado, então caem junto. Continua sendo trabalho do Resolvedor, não decisao-dono.

@caioross

Copy link
Copy Markdown
Owner Author

🩺 PR Doctor — encerro esta PR (a necessidade #34 continua viva; o branch não)

Terceira rodada em que esta PR aparece como "assumo depois". O HANDBOOK me obriga a
reparar ou dizer por que deixou de valer — e a resposta honesta, medida agora contra
origin/main, é a segunda. Não é reprovação do trabalho: é a base que passou por cima dele.

1. O que ainda vale (e não é pouco): a issue #34 está aberta e o furo está aberto

Reconferido no head da main de hoje: src/app/api/rooms/[code]/route.ts:126-149 (case "finish")
faz apenas sanitizeResults e o flip condicional. Nenhuma coerência temporal existe em
produção — src/lib/room.ts:302 capa em MAX_PLAUSIBLE_WPM = 350 e nada mais. O ataque
descrito na #34 continua 100% explorável.
Por isso não fecho a issue; devolvo-a à fila.

2. Por que o branch não é mais reparável por união

(a) A lógica foi vetada e precisa ser reescrita inteira — o quórum em e6f0b89 provou dois
defeitos independentes (sinal invertido: (chars/5)/elapsed é piso, não teto; e now global
em vez de r.finishedAt por jogador). Os 12 casos novos em validate-persistence.mjs codificam
o modelo errado
, então caem junto. Nada de plausibleWpmCeiling/validateFinishTiming
sobrevive à correção.

(b) A parte de route.ts hoje é CONTRA a main — e este é o fato novo desde o último parecer.
A PR #96 (mergeada em 26/07) decidiu deliberadamente o oposto do que esta PR faz:

esta PR (route.ts:100-101) main hoje (route.ts:137-139)
flip condicional não casou 409 "Sala não está em corrida" ok: true, sem repersistir

A main documenta em comentário que zero linhas é o caminho feliz da corrida com vários
clientes ("outro já finalizou") — foi a regressão de UX que o review automático já apontava aqui
em 15/07. Mergear esta parte hoje seria desfazer uma decisão explícita e recente da main.

(c) O reset deste branch é pré-kicked_ids (route.ts:106-112, 4 linhas) enquanto a main
tem a versão em statements separados por causa da migração 0005 — sintoma de quão para trás o
branch ficou no mesmo arquivo.

(d) A aritmética da dívida. Desde a base comum e04aeff, nos mesmos 3 arquivos:

arquivo main andou esta PR traz merge-tree
src/lib/room.ts +351 / −5 +57 ❌ CONFLICT
src/app/api/rooms/[code]/route.ts +145 / −49 +28 / −4 ❌ CONFLICT
scripts/validate-persistence.mjs +156 / −7 +81 ❌ CONFLICT

Conflito nos três, e 100% do que sairia do merge seria reescrito ou revertido em seguida.
Reparar por união aqui é trabalho negativo — produz um branch conflitado para jogar fora.

3. Encaminhamento

Não é decisao-dono: nada aqui pede decisão humana.

@caioross caioross closed this Jul 28, 2026
caioross added a commit that referenced this pull request Aug 2, 2026
…da (#124)

O §5 dizia que uma issue com branch remota `auto/issue-<N>-*` não é elegível,
sem qualificar o estado da PR. Como o §8 proíbe apagar branch, toda PR fechada
sem merge congelava sua issue para sempre.

Custo medido: a #34 (P1, furo de anti-cheat aberto na `main`) ficou top-1 do
Curador por três rodadas e foi pulada duas vezes — a branch
`auto/issue-34-finish-timing` era resíduo da PR #36, CLOSED com `mergedAt:null`
desde 28/07. Só destravou porque o Curador escreveu um ruling dentro da issue
em 31/07 e pediu a correção da letra ao Meta.

- HANDBOOK §5: o bloqueio passa a ser "branch remota COM PR aberta vinculada";
  branch órfã = resíduo, e o caminho correto é abrir branch nova.
- cr-fleet-ops §2: a checagem (b)/(c) vira uma só, com o comando que decide
  (`gh pr list --state all --head <branch>`).

Somente markdown — nenhum arquivo executável tocado, gate §6 não se aplica.
Closes nada; melhoria de processo pedida pelo Curador em 2026-07-31.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant