# Handoff — Money-path hardening: arco C + flags R6 + itens A/C/D/E (falta B) — 2026-07-17

`origin/main = HEAD = 870c0eac`, working tree limpa. Tudo abaixo pushado.

## 0. Retomada rápida

1. `/retomar` (carrega o procedimento de retomada; lê o handoff mais novo — este).
2. Leia este handoff (§7 = o que falta: item B) + o design `docs/plans/2026-07-16-money-path-design-para-aprovar.md`.
3. Confira `git rev-parse origin/main HEAD` = `870c0eac`. Suba os containers de teste (§6) antes de qualquer `mix test`.
4. Ataque o **item B** (§7) como incremento próprio (é o de maior risco).

## 1. Revisões vivas (ECS, conferidas)

| Serviço | HML | PROD |
|---|---|---|
| core-api | :156 | :60 |
| pix-api | :180 | :57 |
| spb-api | :66 | :37 |

Rollbacks (revisão imediatamente anterior): HML core:155 pix:179 spb:65; PROD core:59 pix:56 spb:36. Todos ACTIVE (`ecs update-service --task-definition <family>:<rev>`).

## 2. O que foi entregue e DEPLOYADO (HML+PROD)

Ordem cronológica; cada item com TDD, commit e push.

### Arco C da auditoria money-path (R6 + ports 5-8) — commit `53d47b9e`+`069a72d7`
- **R6 / port 5:** `StuckOutboundChecker` (Core) consulta a cabine por E2E (`CabinStatusLookup`) quando não há evidência LOCAL. Cabine confirma REJEIÇÃO terminal → termina via `VoidGuard` (money-safe, atrás da flag). Settled polido / ausente / indecidível → QUARENTENA explícita (NÃO muda stage, NÃO toca dinheiro; o saldo segue protegido pela reserva no TB) + alerta humano. Cabine indisponível → comportamento anterior. Knob `STUCK_OUTBOUND_QUARANTINE_MINUTES` (default 120).
- **Port 6 (alerta humano):** `Monetarie.UseCases.Alerts.Operational.raise/1` (persiste em `alerts` + notification broadcast, dedup por `dedup_key`). Ligado no `StuckOutboundChecker` e no `DlqMonitorWorker`. BalanceGuard ficou de FORA do gate de propósito (divergência in-flight é normal; alerta de balanço pertence a uma varredura, não ao caminho quente ANS 1,6s).
- **Port 7 (teto de devolução):** `Shared.Returns.CumulativeCap.guard/4` — teto por E2E original sob `pg_advisory_xact_lock`, DENTRO da transação que cria a pacs.004. Soma pela fonte REAL `json_input->>'amount'` (centavos; `amount` é virtual em `messages`), ignora rejeitadas (8/9). Fail-closed. Ligado nos DOIS caminhos (partner/v2 `handle_return_request` + `ReturnProcessor`).
- **Port 8 (reserva persistida):** `Shared.Dict.E2eReservation` + migration `20260716230000_create_dict_e2e_reservations` — espelho durável do `E2eCache`, lido como FALLBACK quando o Redis está vazio (restart do ElastiCache). Fail-soft.

### Flags do R6 LIGADAS — commit `add7dc15`
- `STUCK_OUTBOUND_AUTO_RESOLVE_ENABLED`, `PIX_OUT_RETRY_QUEUE_ENABLED`, `PIX_OUT_PENDING_RECOVERY_VOID_ENABLED` = `true` (env no task-def core-api, HML+PROD).
- **LANDMINE corrigido ANTES de ligar:** a queue `:pix_out_retry` (do `PixOutRetryWorker`) estava só no `config.exs` (dev/test), AUSENTE do `runtime.exs` (prod). Ligar a fila teria empilhado PIX-out preso numa fila SEM worker (job preso em silêncio). Registrada + **`ObanQueueCoverageTest`** (guarda: toda queue de `use Oban.Worker` tem que estar no `runtime.exs`).
- **GOTCHA:** `runtime.exs` é baked na imagem (release) → mudar a lista de queues exige REBUILD, não só env.

### Item D (fim do ACK silencioso na cabine) — commit `87a19f85`, pix-api
- Catch-all do `CoreEventProcessor` (settlement_service) fail-loud: payload de money-path no subject `monetarie.core.pix.>` sem event roteável → log ERRO + telemetria `[:core_event_processor, :unrouted]` + DLQ durável (`monetarie.dlq.core.pix` → `MONETARIE_DLQ` → `DlqDepthMonitor` alerta). Nunca mais um warning + ACK calado.
- payables/open_finance PIX seguem stubs INCOMPLETOS (sem account_id/hold_id) — NÃO roteados à força; agora VISÍVEIS na DLQ em vez de sumir. Fechá-los ponta a ponta é tarefa própria (follow-up).

### Item E (TED não presa no esgotamento do retry) — commit `7ebf556f`, spb-api
- `CoreEventConsumer` (SPB): NAK/reentrega transitória até `max_deliver` (5). No ESGOTAMENTO, desfecho terminal money-safe:
  - erro **PRÉ-DESPACHO** (MQ/HSM/cert/conexão fora → a TED provadamente nunca chegou ao BACEN) → rejeita ao Core (`SENDER? não` — libera o hold, cliente reenvia) + DLQ;
  - erro **AMBÍGUO** (`:timeout` pode ter ido ao MQ) → QUARENTENA (hold PRESERVADO, NÃO rejeita, DLQ + telemetria `[:bacen_gateway, :core_event, :exhausted]` para reconciliação). Nunca arrisca duplo-pagamento.
- Contagem de tentativas AUTORITATIVA do JetStream (`parse_num_delivered` do `$JS.ACK`, formatos v1/v2) + fallback ao contador em memória. `transient?` mantém default `true` (retry barato; o esgotamento garante o terminal se o erro for permanente).

### Item A (TED agendada por data de liquidação) — commit `5831351b`, core-api + MIGRATION
- **REGRA DO DONO (corrigida):** um agendamento NÃO promove hold. O dinheiro fica LIVRE na conta até a data; a TED agendada guarda o pedido + avisa o cliente. Na abertura da grade STR (~8h BRT) o executor confere saldo e ENVIA (hold+débito+STR0008) OU REJEITA por insuficiência (webhook `ted.failed`). Débito DIFERIDO, espelhando o **PIX AGENDADO** (`ScheduledPix`+`ScheduledPixExecutor`).
- Peças: migration `20260717120000_create_scheduled_teds` (aplicada via rpc HML+PROD); schema `Schemas.Spb.ScheduledTed`; use case `UseCases.Spb.ScheduledTed` (`classify_settlement/1`, `create/1` sem hold, `execute_due/3` com dispatch/notify injetáveis, claim idempotente); executor `Workers.Spb.ScheduledTedExecutor` (cron `0,15,30 11 * * 1-5` = 8h/8h15/8h30 BRT, catch-up idempotente; BRT=UTC-3 sem DST); `TedController.create` ramifica imediata (`create_immediate`) vs agendada (`create_scheduled`).
- Follow-up: partner_v1/v2 transfer controllers podem reusar o `ScheduledTed` (o `scheduledDate` da v2 hoje é ignorado pela cabine).

### Item C (sender_ispb da pacs.008 fail-closed) — commit `e8965323`, pix-api
- `resolve_sender_ispb` (settlement_service `core_event_processor.ex`) virou EXPLÍCITO e FAIL-CLOSED: função pura `classify_sender_ispb/4` (participante conhecido → indireto/liquidante, direto/próprio; fallback ao participante direto = Monetarie no SCD; ou a própria ISPB → `{:ok, ispb}`; senão `{:error, :unresolvable_sender_ispb}`). O `do_payment_request` rejeita ao Core (`SENDER_ISPB_UNRESOLVABLE`) ANTES de assinar/bloquear (fim do "usa o ISPB as-is" que mandava pacs.008 com ISPB chutado). Fluxo pós-resolução extraído para `resolve_e2e_then_pay/7`.
- `local_instrument` **NÃO** alterado de propósito: a inferência atual JÁ segue a regra do design (chave→DICT, agência/conta→MANU no builder; QR→QRES/QRDN via `QRCodes.resolve_local_instrument`; validada pelo `Pacs008SendValidator`). Mudar wire-value sem defeito = risco à toa.

## 3. Suítes no fecho (todas verdes)
- Core: workers/alerts/payments 367/0 + cron parity 3/0 + queue coverage; ScheduledTed 11/0 + TedController 4/0; spb/workers/ted 261/0.
- Cabine: CumulativeCap 6/0, E2eReservation 5/0, exhaustion 9/0 + consumers/lifecycle/nats 63/0; classify_sender_ispb 7/0 + core_event_processor 70/0; settlement QR 184/0; shared crypto/pix 277/0.

## 4. Estado das FLAGS em produção (conscientemente ligadas)
As 3 flags de auto-resolução do R6 estão ON em HML e PROD. Com elas o Core resolve PIX-out preso sozinho (termina rejeitados-no-BACEN via VoidGuard, quarentena para indecidíveis com alerta, retry queue real para sem-evidência). Nada estava preso na virada (flip seguro, zero void em massa). Rollback = remover as 3 envs do task-def OU voltar core-api :155(HML)/:59(PROD). NÃO desligar sem motivo — elas fecham o buraco de "mensagem/PIX-out preso".

## 5. Chamados de integração #108-#111 (contexto, já deployado dias antes)
QR payload URL = JWS puro `application/jose`; `ted.received` com titular de origem; merchant DICT erro = 422; QR valor aberto = estático; itens 1-3 (QR estático na Partner API, create_charge delega ao motor dinâmico, JWS PS256). Detalhe: `docs/reports/2026-07-16-chamados-integracao-108-111-respostas.md`.

## 6. Ambiente local (GOTCHAS de teste/build)
- **Docker/OrbStack precisa estar UP.** Se caiu: `open -a OrbStack` e aguardar o socket `/Users/luizpenha/.orbstack/run/docker.sock`.
- **Testes do Core** exigem os containers `monetarie-pg` (Postgres teste, :15432) e `monetarie-tb-test` (TigerBeetle). Se pararam: `docker start monetarie-pg monetarie-tb-test`; aguardar `docker exec monetarie-pg pg_isready -U postgres`.
- Migration local: `MIX_ENV=test mix ecto.migrate` (DB `mon_core_test`).
- **Túnel SSM em localhost:15432 sombreia o `monetarie-pg`** de teste — `lsof -i :15432` antes de `mix test`.
- Cabine: `mix test apps/<app>/test/...` na umbrella `pix/backend`.
- **JMESPath:** `contains(X, \`-50\`)` parseia como número e explode — usar aspas simples `'-50'`.

## 7. Item B (outbox cross-repo, ALTO risco) — **IMPLEMENTADO 2026-07-17 (`7996191f`, pushado; deploy pix-api AGUARDA OK do dono, sem migration)**

**STATUS: FEITO com TDD (RED provado) + revisão adversarial (nada crítico/alto).** Fix: `Shared.Outbox.ObanRouting` (repo→instância: Settlement→`SettlementService.Oban`, Spi→`SpiService.Oban`, Shared→`Oban`) resolve pela transação aberta no processo; `publish_async` insere o job por ela (`Oban.insert/2`); `RuntimeGuard` só aceita transação em repo roteável (o comentário antigo do "piggyback" era FALSO — Oban.insert usa o repo da INSTÂNCIA). Prova: rollback pós-enqueue não deixa job em nenhuma conexão; commit publica exatamente 1 pelo repo da transação (testes `outbox_cross_repo_atomicity_test.exs` em settlement e spi). Bônus provado na revisão: fecha janela latente de duplo pagamento por crash entre commits no `ExecutionWorker` de recorrências. Suítes: shared 1752/0, settlement 974/0, spi 855/0, dict 403/0. Texto original do problema abaixo.

**PRÉ-EXISTENTES corrigidos na sequência (`cf49db0c`, pushado, TDD + 2ª revisão adversarial):** (a) flake 25P02 MORTO — cache positivo de `system_configs_table_exists?` (`:persistent_term`) envenenado por tabela transitória de outro teste (rollback desfaz a tabela, não o cache); lookup real contra tabela ausente abortava a tx do money path; fix = sem cache, to_regclass por chamada + query única p/ as 2 chaves de reserva; teste determinístico `spi_validator_config_taint_test.exs`; (b) DESCOBERTA: guard I-1 INERTE em rodadas de `mix test` por app (`MoneyPath.relativize` dependia do cwd; prod só funcionava porque WORKDIR do build == cwd do runtime, verificado no Dockerfile); fix = reancoragem no último componente `apps/`; guard reativado, 4 suítes com zero violações; (c) `money_path: false` honrado no `RuntimeGuard` (usos em lib auditados um a um, todos legítimos); (d) PromEx `oban_supervisors` = `[Oban, SettlementService.Oban, SpiService.Oban]` + `PromExObanCoverageTest` amarrado ao `ObanRouting.routes()`. Suítes pós-fix: shared 1753/0, settlement 977/0, spi 856/0 (incl. seed 381640 do flake), dict 403/0. Follow-ups menores registrados: gate estático (Credo) para `money_path: false` dentro de módulo money-path; o filename que o Credo passa em rodada por app ainda não reancora (checks I-3/I-4 valem da raiz da umbrella).
**Problema:** no `CoreEventProcessor` (cabine, `apps/settlement_service/.../workers/core_event_processor.ex`), `Publisher.publish_async` usa o Oban do `Shared.Repo`, mas a `Repo.transaction` que cria a linha `messages` é do `SettlementService.Repo`. O job de outbox (`transaction.created`, `outbound.send`) commita INDEPENDENTE do rollback da linha `messages`. Um rollback após o enqueue pode deixar `transaction.created` publicado para uma tx que não existe. É a mesma classe do defeito da queima de E2E (que passou a rodar no repo da transação).

**Correção proposta (design §B):** enfileirar o job de outbox no MESMO repo da transação envolvente — nomear a instância Oban do `SettlementService.Repo` (`SettlementService.Oban`) e fazer o `Publisher.publish_async` do money-path do processor inserir nela; OU mover o publish para pós-commit com um outbox durável próprio do `SettlementService.Repo`. Alinhar o `RuntimeGuard` para exigir repo-do-job == repo-da-transação.

**Risco:** ALTO — núcleo do padrão outbox; mexer errado quebra a atomicidade de TODO o money-path. Incremento ISOLADO e revisado, com prova: transação que faz rollback após o enqueue NÃO deixa job publicado (por `oban_jobs`/pending); sucesso publica exatamente uma vez.

**Deploy do B:** só cabine (pix-api), sem migration esperada. Build + deploy HML→PROD na ordem de sempre; ICOM reassume sozinho (verificar `Coordinator] leader/resuming` nos logs pós-swap).

## 8. Receitas de deploy (scripts no scratchpad da sessão, replicáveis)
- Build+push arm64: `docker buildx build --platform linux/arm64 -t $REG/monetarie/<svc>:<tag> --push <context>` (contextos: `core/backend`, `pix/backend`, `spb/services/bacen_gateway`).
- Deploy 1 serviço preservando env/secrets/sidecar (troca só a imagem): `describe-task-definition` → jq muda `.image` do container → `register-task-definition` → `update-service` → `wait services-stable` (ou poll `deployments[0].rolloutState == COMPLETED`; o waiter pode estourar tentativas numa estabilização longa do ALB, não é falha).
- PROD por MESMO digest: `docker buildx imagetools create -t <prodtag> <homologtag>` + guard MATCH dos digests (NUNCA `ecr batch-get-image`/`put-image`, que muda o digest do índice OCI).
- Migration via ECS exec no container NOVO: core `bin/monetarie rpc "Monetarie.Release.migrate()"`; cabine `bin/monetarie_pix rpc "Shared.Release.migrate()"`. (O "Cannot perform start session: EOF" ao fim é o encerramento normal do canal SSM.)
- Ordem quando há migration: deploy do serviço (imagem com a migration) → rpc migrate → (se cross-serviço) deploy dos demais.
- AWS: `AWS_PROFILE=vulcimonetarie AWS_REGION=sa-east-1`, conta `990933657879`.

## 9. Memórias atualizadas (4 sistemas)
- **CLAUDE.md** (raiz) — estado canônico 2026-07-17 NOITE 4 (topo).
- **MEMORY.md** — índice compactado (<17.1KB); linhas dos itens A/C/D/E + arco C.
- **Serena** — `monetarie/money-path-hardening-0717`.
- **claude-mem** — observações #111153 (item A), #111206 (item C).
- Memórias-arquivo locais: `monetarie-item-a-ted-agendada-0717`, `monetarie-item-c-sender-ispb-failclosed-0717`, `monetarie-r6-flags-item-d-0716`, `monetarie-arco-c-r6-ports5-8-0716`.

## 10. Follow-ups (não-bloqueantes)
- payables/open_finance PIX: stubs incompletos, hoje só visíveis na DLQ; fechar ponta a ponta se forem fluxos reais.
- TED agendada nos controllers partner_v1/v2 (reusar `ScheduledTed`).
- DLQ triagem (acervo histórico), CI inexistente, contador COSIF (SME/LPI) — pendências gerais antigas.
- Performance/custo no geral (frente própria pedida pelo dono; não iniciada).
