diff --git a/docs/changelog/2026-08-24-ops46-s3.md b/docs/changelog/2026-08-24-ops46-s3.md
new file mode 100644
index 00000000..4d21176b
--- /dev/null
+++ b/docs/changelog/2026-08-24-ops46-s3.md
@@ -0,0 +1 @@
+**OPS46-S3 — débito(OPS46): teste de steal do allocator depende do cursor visitar o slot stale (flake sob carga) (2026-08-24):** `tests/int/campaignMunicipalityAllocator.int.spec.ts` reescrito determinístico — `it('steals claims of a crashed run')` agora consulta o `index` do claim envelhecido (`SELECT "index" WHERE run_id = crashed`) e dirige o cursor compartilhado até ele (`loop attempt < catalogSize` via `claimMunicipalityIndex` até `claimed === targetIndex`), com branch `null` (já roubado por outra spec paralela antes do SELECT) e fallback `claimCount(crashed) === 0` (roubo concorrente durante o drive) — invariante `claimCount(crashed) === 0` preservado, bound `catalogSize` (40) substitui o probabilístico `catalogSize*3` (120). Owner `tests/helpers/campaignMunicipalityAllocator.ts` não tocado. Estabilidade: `campaignMunicipalityAllocator` verde isolado ×3 e suíte int completa (94 files / 836 tests) verde. Plano: `docs/plans/ops46-debt-steal-test-deterministico-impl.md`.
diff --git a/docs/plans/ops46-debt-steal-test-deterministico-impl.md b/docs/plans/ops46-debt-steal-test-deterministico-impl.md
new file mode 100644
index 00000000..0cf4be2e
--- /dev/null
+++ b/docs/plans/ops46-debt-steal-test-deterministico-impl.md
@@ -0,0 +1,72 @@
+# Impl: Débito — teste de steal do allocator dependente do cursor
+
+Status: aprovado
+Atualizado em: 2026-08-24
+Issue: #790
+Intenção: docs/plans/ops46-debt-steal-test-deterministico.md
+Appetite restante: P3 chore — mínimo (herdado)
+
+## Leitura da intenção
+
+- **Outcome:** `tests/int/campaignMunicipalityAllocator.int.spec.ts:106-136` estável sob suíte int completa em re-runs — sem flake por cursor não visitar slot stale.
+- **O que NÃO negociar:** invariante `claimCount(crashed)==0` após steal; TTL `STALE_CLAIM_WINDOW = '2 hours'` (`tests/helpers/campaignMunicipalityAllocator.ts:33`); owner único `tests/helpers/campaignMunicipalityAllocator.ts` (vitest-free, sequence `campaign_fixture_municipality_alloc` + claims table) — não twinar; `push:false` / sem migration de produto (test-infra).
+- **O que reavaliar:** "precisa mexer no owner para expor `tryStealStaleClaim`" — refutado; SELECT do `index` já é possível com padrão `claimCount` existente, sem expor interna.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ A[claim crashed 1 slot] --> B[UPDATE claimed_at = now-3h]
+ B --> C[SELECT index WHERE run_id=crashed]
+ C --> D{index null?}
+ D -->|sim| E[ja stolen por outra spec
passa direto]
+ D -->|nao| F[loop claimMunicipalityIndex survivor
ate retornar targetIndex
bound catalogSize]
+ F --> G[assert claimCount crashed == 0]
+ E --> G
+ H[Antes: loop 120 probabilistico
P miss >0 sob concorrencia] -.-x G
+```
+
+**Opções consideradas:** A | B | C
+**Recomendação:** **A — SELECT do índice alvo + cursor dirigido via API pública** — porque torna o teste determinístico com bound `catalogSize` sem expor internas, reusando só `claimMunicipalityIndex` (contrato público já consumido por `campaignFixtures.ts` e `campaignE2EFixtures.ts`) e padrão SQL já estabelecido no próprio spec.
+**Rejeitadas:** B porque exporia `tryStealStaleClaim`/`nextAllocationValue` do owner para teste — cria acoplamento e viola "Edit the owner, don't twin" / guard `testMunicipalityAllocatorConventions` que proíbe `nextval` fora do owner; C porque manter loop probabilístico com bound maior (120→N) ou sleep só reduz `P(miss)`, não zera — continua flaky sob carga paralela.
+
+### Componentes / mudanças
+
+- **`tests/int/campaignMunicipalityAllocator.int.spec.ts:106-136`** (único arquivo tocado): reescrever `it('steals claims of a crashed run...')` — após `UPDATE claimed_at` fazer `SELECT "index" FROM "campaign_fixture_municipality_claims" WHERE "run_id"=${crashed}` (mesmo padrão de `claimCount:36-43` com `sql`+`sql.raw` para identificadores); se `null` → já stolen (outra spec roubou antes da query — tratar como sucesso, não falhar); senão loop `for attempt < catalogSize` chamando `claimMunicipalityIndex(payload, catalogSize, survivor)` até `returned === targetIndex`, então `expect(claimCount(crashed)).toBe(0)`. Manter `releaseMunicipalityClaims` no final.
+- **`tests/helpers/campaignMunicipalityAllocator.ts`** — **não alterar** (owner vitest-free intacto; depth check: reusar).
+- **Migration:** sem migration (só teste).
+- **Access / Consent:** não se aplica (test-infra, sem `src/`).
+- **UI:** não se aplica.
+
+### Dados → forma (se aplicável)
+
+Não se aplica — test-infra puro (read SQL + API pública). Forma rejeitada: helper novo no owner para "steal dirigido".
+
+## Fases verificáveis
+
+1. **Tracer — steal determinístico** (quota: minutos): editar só o `it` stale; `pnpm test:int -- campaignMunicipalityAllocator` verde isolado; checar `SELECT index` retorna 1 row e loop consome ≤`catalogSize` claims.
+2. **Estabilidade sob concorrência** — suíte int completa `pnpm test:int` (92 arquivos / ~819 testes) ×3 re-runs verdes; suíte int sem filtro (todos workers paralelos) — alvo nunca falha por miss de cursor.
+3. **Gates** — `pnpm gate:fast` (lint + typecheck + unit); `pnpm check:cycles`; `pnpm exec knip` sem novo erro; `pnpm push` (gate:push replica `ci-pr.yml` checks).
+
+## Rabbit holes / Não escopo (engenharia)
+
+- **Outro spec rouba stale antes do SELECT → `index=null`:** tratar como já stolen (assert `claimCount==0` passa) — não falhar nem re-criar claim. Não adicionar retry de SELECT.
+- **Expor `tryStealStaleClaim`/`nextval` para teste:** não escopo — manter encapsulamento do owner.
+- **Loop sem bound ou bound > catalogSize:** não escopo — `claimMunicipalityIndex` já itera `catalogSize` internamente (`campaignMunicipalityAllocator.ts:104`); bound externo = `catalogSize` é suficiente para visitar todo espaço uma vez.
+- **Mudar TTL `STALE_CLAIM_WINDOW` (2h) ou `claimed_at` aging para outro valor:** não escopo — TTL é contrato de produção do registry.
+- **Invariantes do `engineering-brief` (Consent/LGPD, URL pública):** não aplicáveis — test-infra; mas "não twinar owner" permanece.
+
+## Riscos e mitigação
+
+- **Race `SELECT` vs steal concorrente (janela entre UPDATE e SELECT):** mitigado pelo branch `index==null` → considera já stolen; invariante final ainda é `claimCount==0`.
+- **Loop dirigido nunca retorna `targetIndex` (catálogo cheio de claims vivos):** com `catalogSize=40` sintético e só 2 runs no teste, 38 slots livres — exaustão impossível; bound garante saída determinística sem hang.
+- **Regressão do guard `testMunicipalityAllocatorConventions`:** mitigado por usar só `claimMunicipalityIndex` pública + `sql`/`sql.raw` (padrões já permitidos).
+- **Flake mascarado virar falso-positivo:** mitigado por assert duplo — `stolen` via chegada no índice OU `null` inicial + `claimCount(crashed)==0` final.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção ainda coberto (spec estável sob suíte int completa, invariante `claimCount(crashed)==0` preservado)
+- [ ] Invariantes AGENTS/engineering-standards (só teste; sem migration; sem twinar owner; `push:false` respeitado)
+- [ ] Testes de domínio previstos (int spec existente reescrito determinístico; sem novo teste necessário; suíte int completa como prova)
+
+Self-score decision-quality: **5/5** — 1) decisões caras com rejeitadas (A|B|C + porquês); 2) cabe no appetite P3 mínimo (1 arquivo, só teste); 3) rabbit holes nomeados (5); 4) depth check reusa owner/helpers existentes; 5) outcome da intenção intacto (engenharia não reescreveu produto).
diff --git a/tests/int/campaignMunicipalityAllocator.int.spec.ts b/tests/int/campaignMunicipalityAllocator.int.spec.ts
index 40ad8843..ef95b07f 100644
--- a/tests/int/campaignMunicipalityAllocator.int.spec.ts
+++ b/tests/int/campaignMunicipalityAllocator.int.spec.ts
@@ -116,16 +116,31 @@ describe('campaign municipality allocator registry', () => {
WHERE "run_id" = ${crashed}
`)
- // A stale claim must not block the space forever: keep claiming until
- // the crashed run's claim is gone. Under the PARALLEL suite another spec
- // may steal the aged slot first (shared cursor), so the invariant is
- // "the stale claim disappears while the survivor claims succeed" — not
- // "the survivor lands on that exact slot".
- let stolen = false
- for (let attempt = 0; attempt < catalogSize * 3 && !stolen; attempt += 1) {
- await claimMunicipalityIndex(payload, catalogSize, survivor)
- if ((await claimCount(crashed)) === 0) stolen = true
+ // Deterministic steal (OPS46-S3): query the stale slot's index and drive
+ // the shared cursor directly to it. The old loop (catalogSize*3 claims)
+ // depended on the cursor probabilistically visiting the stale slot within
+ // the bound — under heavy parallel load the visit could miss the window
+ // and flake. Under the PARALLEL suite another spec may steal the aged
+ // slot first (shared cursor), so a null SELECT means "already stolen".
+ const targetResult = await payload.db.drizzle.execute(sql`
+ SELECT "index" FROM "campaign_fixture_municipality_claims" WHERE "run_id" = ${crashed}
+ `)
+ const targetIndex =
+ targetResult.rows.length > 0
+ ? Number((targetResult.rows[0] as { index: number }).index)
+ : null
+
+ let stolen = targetIndex === null
+ if (!stolen) {
+ for (let attempt = 0; attempt < catalogSize && !stolen; attempt += 1) {
+ const claimed = await claimMunicipalityIndex(payload, catalogSize, survivor)
+ if (claimed === targetIndex) stolen = true
+ else if ((await claimCount(crashed)) === 0) stolen = true
+ }
}
+ // If another spec stole between the SELECT and our loop, claimCount
+ // already reflects it — still a successful steal for the invariant.
+ if (!stolen && (await claimCount(crashed)) === 0) stolen = true
expect(stolen, 'a stale claim must be stolen (or the slot reclaimed) within the loop').toBe(
true,
)