ADR-0044: Núcleo portável — Cloudflare como plataforma de deploy, não modelo de programação
Status: accepted Date: 2026-09-11 Deciders: @barateza + sessão de grilling (big-fat-refactor-03) Amends: ADR-0028 (fronteiras de persistência), ADR-0039 §1 (backend-primary) Tags: [portability, ports, adapters, durable-objects, d1, cloudflare, concurrency, event-store, session-coordinator, adr-0028, adr-0039]
Context
O Neemias roda inteiro sobre Cloudflare: Workers (HTTP), D1 (estado), Durable Objects (fan-out de feed) e R2 (objetos). Cloudflare não é o problema — é uma plataforma de deploy excelente e barata. O problema é outra coisa:
O domínio fala Cloudflare.
Um detalhe de plataforma que atravessa services/, events/ e o modelo mental dos agentes custa duas vezes: uma para escrever, e outra — a cara — para sair. Se amanhã o D1 ou o R2 deixarem de servir, a migração não é um projeto de infraestrutura, é um rewrite.
Evidência verificada no repositório em 2026-09-11 (não é impressão, é medição):
| Observação | Medida |
|---|---|
Arquivos em workers/src que importam @cloudflare/workers-types | 59 |
services/checkinService.ts | 1 127 linhas, importa D1Database, e mistura geração de código HMAC, configuração, validação, SQL de capacidade, criação de evento e fan-out de feed |
events/eventStore.ts expõe mecanismo, não capacidade | executeProjection(sql, params) / executeBatch(stmts) |
| Durable Object coordenando sessão | nenhum — os 3 DOs existentes são fan-out de feed (ADR-0043) |
Constraint de unicidade em (entity_type, entity_id, version) | não existe (só o índice não-único idx_events_entity) |
Emenda (2026-09-12): a última linha desta tabela deixou de valer. A medição acima fica como foi feita, em 2026-09-11; o que mudou desde então está aqui.
O B2 (
migrations/0016_events_unique_entity_version.sql) criouUNIQUE(entity_type, entity_id, version)emeventse derrubou o índice não-únicoidx_events_entity. O B3 passou a classificar a recusa do banco: uma violação do índice composto viraVERSION_CONFLICT, e umevent_idrepetido vira replay idempotente. Os dois andam juntos porque, sem a classificação, a corrida que o índice fecha passaria a responder 500 no lugar de 409 — a constraint sozinha trocaria a duplicata silenciosa por um erro de servidor. A auditoria prévia (B1) achou 0 grupos duplicados nas duas bases.
A tentação errada, ao ver isso, é criar uma abstração de plataforma: um Platform monolítico, um ORM, um "repository genérico" para cada tabela. Isso troca um acoplamento por outro, mais caro de manter. O ADR-0028 já ratificou três padrões de persistência legítimos e deliberados; este ADR não os substitui. Ele nomeia onde fica a fronteira de portabilidade — e, principalmente, impede que ela se mova por acidente.
Decision
1. Três invariantes
Estas três frases são a decisão. O resto do ADR é consequência delas.
Invariante 1:
D1 é o estado persistente autoritativo.
Invariante 2:
Durable Objects coordenam concorrência com escopo de sessão, mas não definem
estado de domínio.
Invariante 3:
Código de negócio/aplicação depende de interfaces de capacidade (ports),
não de APIs de runtime da Cloudflare.2. Direção de dependência
HTTP / Worker
routes, auth, headers
│
▼
Application
use cases / services
│
ports (interfaces)
│
┌────────────────┼────────────────┐
▼ ▼ ▼
Persistence Coordination Object-store
│ │ │
▼ ▼ ▼
D1 adapter DO adapter R2 adapter
PG adapter Postgres-lock adpt. S3 adapterO que é proibido, e a regra que a lint mecânica aplica:
PERMITIDO: PROIBIDO:
domain / application / ports domain / application / ports
↓ ↓
interfaces de capacidade D1Database / DurableObjectNamespace
/ R2BucketTipos específicos de Workers terminam na fronteira de infraestrutura.
3. Ports pequenos e focados — nunca um Platform
workers/src/ports/:
attendanceRepository.ts operações de negócio (getActiveCheckIn, recordCheckIn…)
NUNCA execute(sql) / query<T>(…)
capacityRepository.ts get / adjust — o repositório lê/escreve estado;
"posso fazer check-in?" é comportamento de domínio
eventStore.ts getById / getAggregateVersion / append / appendBatch
sessionCoordinator.ts execute<T>(sessionId, operation)
eventDispatcher.ts publish(event)
objectStore.ts put / get / delete
clock.ts now()Regra de desenho: somente criar port onde a dependência é consequente. Um port para cada tabela é o não-objetivo (§8).
4. SessionCoordinator é o seam mais importante — e o mais fácil de errar
export interface SessionCoordinator {
execute<T>(sessionId: string, operation: () => Promise<T>): Promise<T>;
}A aplicação não pede "um Durable Object". Ela pede "serialize esta operação com escopo de sessão" — que é um requisito genérico de sistemas distribuídos, não uma feature da Cloudflare. As implementações:
ImmediateSessionCoordinator testes / deploy simples (sem plataforma)
DurableObjectSessionCoordinator Cloudflare: namespace.idFromName(sessionId)Duas armadilhas, ambas explicitamente rejeitadas:
- Não colocar a transação D1 dentro do
SessionCoordinator. Umcoordinator.transaction(sessionId, cb)faria o coordenador passar a ser dono da semântica de persistência. A separação é: o coordinator garante ordenação; o repositório garante persistência; o adaptador D1 compõe os dois onde fizer sentido. - O DO é fino. Ele é um mutex distribuído com identidade endereçável, não "o servidor de Session".
SessionCoordinatorDOnão valida aluno, não conhece papel, não gera código diário, não escreve D1, não gerencia fila de espera e não publica SSE.
5. A correção nunca depende do DO
Adicionar UNIQUE(entity_type, entity_id, version) (migração), após auditar demo/produção por duplicatas e limpar de forma determinística se existirem.
DO → execução ordenada
D1 → aplica o invarianteSe o DO for contornado — problema de deploy, migração, ferramenta administrativa, bug, ou outro caminho de API — o banco continua protegendo a correção. Correção que depende só do DO é correção que some no primeiro incidente.
6. Enforcement mecânico
scripts/no-cloudflare-in-core.sh (gate docs, portanto em todo commit):
- Regra A (absoluta, sem baseline): nenhum arquivo em
workers/src/{domain, application,ports}importa@cloudflare/workers-types. Esses diretórios são o destino do núcleo portável; hoje estão vazios, então a regra é verde por construção e assim permanece. - Regra B (ratchet,
CF_CORE_BASELINEemscripts/thresholds.sh= 26): a contagem de arquivos emworkers/src/{services,events}que importam o runtime Cloudflare não pode aumentar. São o fonte da migração; acoplamento novo ali é deriva, e o número só desce.
A detecção reusa scripts/count-tokens.sh (o scanner que ignora comentários) em vez de grep: um grep não distingue código de prosa, e este próprio ADR cita o especificador do módulo em texto.
Zonas deliberadamente excluídas, por serem casas legítimas de Cloudflare (§7): routes/, middleware/, adapters/, do/, db/, feeds/, storage/, modules/, index.ts, router.ts.
7. O que permanece Cloudflare-específico — e está certo
workers/src/routes/ handlers HTTP
workers/src/middleware/ pipeline, idempotência, rate-limit
workers/src/do/ classes Durable Object
workers/src/adapters/ implementações dos ports
workers/src/db/ bindings D1
workers/src/feeds/ fan-out SSE (ADR-0043)
workers/src/storage/ EntityRepository D1
wrangler.toml configuração de plataformaA única exigência é que esses módulos apontem para dentro:
adaptador Cloudflare → port ✅
domínio → adaptador ❌8. Não-objetivos
- Sem migração para ORM. SQL cru não é o problema de lock-in.
- Sem "hexagonal em todo lugar". Port só onde a dependência é consequente.
- Sem repository genérico por tabela.
D1EntityRepofica para CRUD comum. - Sem explosão de pacotes. Nada de 40 pacotes minúsculos.
- Sem substituir SSE. A arquitetura de feed DO está boa.
- Sem DO por entidade. A fronteira de coordenação é
sessionId. - Sem DO como fonte da verdade. D1 permanece autoritativo.
- Sem Queue no caminho crítico de presença. Fila é para efeito colateral assíncrono; "esta criança pode entrar?" não vai para fila.
9. Relação com ADR-0028 e ADR-0039
- ADR-0028 permanece válido. Os três padrões (D1 direto,
EntityRepository<T>,EventStore+applyEvent) continuam legítimos e não são unificados aqui. Este ADR acrescenta uma coisa que o 0028 não cobria: as implementações D1 desses padrões passam a ficar atrás deports/+adapters/. E corrige um vazamento que o 0028 descreveu mas aceitou: a interface deEventStoreexpõe mecanismo (executeProjection/executeBatch), não capacidade. O reshape dessa interface é o PR-03 do big-fat-refactor-03 e exigirá um amendment ao 0028 — não agora. - ADR-0039 permanece válido e não é reaberto. Backend-primary, D1 autoritativo, escritas REST via
applyEvent, stack offline decoplada por flag: tudo decidido lá, comVITE_ONLINE_ONLY=truejá em produção (IJCP) e demo. A "retirada do proxy SQL do frontend" é execução de uma direção já decidida, não arquitetura nova. A decisão §7 do 0039 (oneemias-dblegado é apagado no flip; a ilha é o único estado local) é a leitura vigente de "manter Dexie" — este ADR não reabre isso.
10. Nome e sequenciamento
O esforço se chama big-fat-refactor-03 porque big-fat-refactor-02 já existe (.specs/features/big-fat-refactor-02/spec.md, issues #637–#642) e trata de outra coisa: simplificação da infraestrutura de qualidade (ADR-0037), não arquitetura.
Sequenciamento em 3 lanes, detalhado em .specs/features/big-fat-refactor-03/spec.md:
Lane A parar o vazamento de tempo, publicar o Modo Fácil v1
Lane B piso de correção: unicidade de versão de agregado +
fan-out de feed desacoplado do commit
Lane C seams de portabilidade (ports → ImmediateSessionCoordinator →
SessionCoordinatorDO por último, com o piso da Lane B como rede)A ordem é deliberada: a Lane C é a mais sedutora e a menos urgente. Nenhum PR da Lane C entrega valor visível ao usuário; as Lanes A e B entregam.
Consequences
Positivas
- A direção de dependência passa a ser verificada mecanicamente, não por disciplina.
- Migrar de plataforma vira um projeto de infraestrutura limitado: reimplementar
ports/sobadapters/(Postgres/S3/coordenação) sem tocar regras de presença, permissões, schemas, eventos de domínio, protocolo de sync, semântica de conflito, relatórios ou LGPD. - A Lane B melhora a correção antes de qualquer DO existir, o que também significa que a adoção do DO não é um evento de risco.
- O ratchet
CF_CORE_BASELINE=26dá um número honesto de progresso.
Negativas
- Um indireção a mais em troca de portabilidade que talvez nunca seja usada. Aceito conscientemente: o custo é um arquivo de interface por capacidade, não um framework.
- O ratchet pode incomodar quando alguém precisar, legitimamente, tocar
services/— nesse caso o caminho é o port, que é exatamente o comportamento desejado. - Ports mal desenhados podem virar indireção sem profundidade. A mitigação é a regra de desenho do §3 (operação de negócio, nunca SQL) e o não-objetivo do §8.
Riscos
- Ratchet virar letra morta. Se
CF_CORE_BASELINEfor aumentado sem ADR, a regra morre. Mitigação: o script diz explicitamente que aumentar exige amendment. - A Lane C começar antes da Lane B. Correção que depende só do DO é o cenário que o §5 existe para impedir. Ordem: migração de unicidade primeiro.
- Deriva entre este ADR e o ADR-0028. O amendment do 0028 (reshape do
EventStore) está declarado e adiado; se o PR-03 acontecer sem ele, o 0028 fica contradizendo o código.
References
.specs/features/big-fat-refactor-03/spec.md— spec do esforço e das 3 lanesscripts/no-cloudflare-in-core.sh— enforcement das regras A e Bscripts/count-tokens.sh— scanner comment-aware reusado pela lint- ADR-0028 — Persistence Pattern Boundaries (três padrões legítimos; não substituído)
- ADR-0039 — Online-Only Default Mode (backend-primary;
VITE_ONLINE_ONLY=trueem produção) - ADR-0037 — Simplify Quality Infrastructure for Solo Dev + LLM (é o "big-fat-refactor-02")
- ADR-0043 — One Definition Per Feed (os DOs existentes são fan-out, não coordenação)
docs/big-fat-refactor-01/architecture-notes.md— evidência original sobre os doissyncEventSchemadivergentes, o event sourcing ilhado e o contrato de plugin quebrado