Skip to content

ADR-0023: Server-Authoritative Model — Command Handler Único + Events como Auditoria

Context

O backend hoje tem dois caminhos de escrita que coexistem sem contrato:

  • Caminho de sync (routes/sync.ts, classSlotService.ts) passa por applyEvent → grava evento + projeção em batch atômico D1.

  • Caminho REST online (students, classes, attendance, users, roles, onboarding, church-events) escreve direto na tabela de projeção, sem gravar em events, sem transação atômica.

Consequências:

  • O log events é incompleto — só contém dados que chegam por sync ou class-slots. Qualquer feature que confie no log (auditoria LGPD, conflito, replay) está errada para dados online.

  • Escritas não-atômicas: deleteStudent faz repo.softDelete() + INSERT student_events em dois .run() separados. Um crash no meio corrompe.

  • approveDraft (onboarding) faz multi-tabela sem transação — crash deixa órfãos.

  • A UI não tem applyEvent; RolesSection.tsx faz INSERT/UPDATE/DELETE direto no store, sem trilha e sem sync.

Há também três logs parciais (events, student_events, attendance_events) que nenhum código consolida.

Alternatives considered

Opção A — Convergir para Event Sourcing puro

Toda mutação passa por command handler que reidrata agregado, valida invariantes, faz append de evento; a projeção é derivada do log.

Prós: log completo, time-travel, replay garantido. Contras: governança de schema de evento + snapshots custa caro para equipe pequena; a maioria das entidades (attendance, class, nucleus) não é history-as-product; convergir seria mais rewrite do que conserto; a arquitetura já é servidor-autoritativa.

Opção B — CRUD tradicional + audit log onde precisar

Cada entidade decide independentemente se usa log de auditoria.

Prós: simples, sem overhead de ES. Contras: inconsistência entre entidades; a decisão "onde precisar" tende a ser adiada indefinidamente; não resolve o problema do caminho único.

Opção C — Servidor autoritativo + command handler único + events como auditoria (ESCOLHIDA)

Modelo dominante na indústria para offline-first centralizado (PowerSync, Smashing Magazine 2026): a projeção é fonte da verdade; todo caminho de escrita converge num command handler que valida + escreve projeção + grava evento de auditoria em batch atômico; o log é append-only mas o sistema NÃO depende de replay.

Prós: resolve o problema real (caminho único + atomicidade + log completo) sem o overhead de ES puro; o log de auditoria atende LGPD e histórico; a migração é incremental (entidade por entidade). Contras: eventos não são fonte da verdade — auditoria e projeção podem divergir se alguém escrever direto na projeção (mitigado pela matriz de mutação em CI).

Decision

Adotar o modelo servidor-autoritativo: projeção como fonte da verdade, command handler único por entidade, events como log de auditoria atômico (não replay).

Concretamente:

  1. A projeção (tabelas de domínio no D1) é a fonte da verdade do estado.
  2. Todo caminho de escrita — online (REST) e sync — converge num único command handler por entidade que, num batch D1 atômico: valida invariante + autorização, escreve projeção e grava evento de auditoria.
  3. A tabela events continua como log de auditoria append-only, mas o sistema NÃO depende de replay para reconstruir estado.
  4. Conflito de sync: role-weight + timestamp, derivado de ROLE_HIERARCHY.

Esta decisão é registrada como parte do refactor em docs/big-fat-refactor-01/refactor-plan.md.

Gates de decisão (resolvidos aqui, moldam fases seguintes)

Gate 1 — Log de auditoria: unificar em events ou manter tabelas por entidade?

Decisão: Unificar em events. As três tabelas parciais (events, student_events, attendance_events) convergem para events com coluna entity_type como discriminador. A estrutura atual de events (0010) já suporta isso; as tabelas por entidade são redundantes.

Consequência: Todo command handler da Fase 4 escreve em events (não em student_events/attendance_events). As tabelas legadas são dropadas após a convergência completa.

Gate 2 — Aggregate boundary do onboarding

Decisão: "Um draft + suas N crianças + seu responsável" é um único agregado com fronteira transacional. O draftId + estado PENDING é a chave de idempotência.

Consequência: approveDraft vira um command handler que faz tudo num batch atômico (cria students, cria user, cria user_roles, cria guardian_links, atualiza draft). Reprocessar é seguro: se o draft já está APPROVED, retorna os mesmos IDs.

Gate 3 — Convenção de rollback por entidade

Decisão: Manter o caminho de escrita antigo no lugar, apenas desconectado, por um release. Apagar só depois de o novo provar-se em produção.

Consequência: Duplicação temporária de código em cada entidade da Fase 4; reverter é uma mudança de roteamento (flag/rota), não um rewrite reverso.

Gate 4 — Arquitetura de módulos + licenciamento

Decisão: Monorepo único com BSL cobrindo tudo; licença como enforcement (signed license files), não separação de repo. Código do módulo vive em modules/packages/ (concluído: de-submodule em 2026-07-07).

Consequência: Contrato de plugin deve ser infra-agnóstico; registerMigrations deve funcionar e ser gated por entitlement; remover duplicação hardcoded do módulo no core. Registrado em detalhe em docs/big-fat-refactor-01/refactor-plan.md Parte 5.

Consequences

Positivas

  • Um único caminho de escrita → invariantes de negócio são garantidas em todo fluxo

  • Log de auditoria completo → LGPD, histórico de mudanças em dados de menor

  • Atomicidade por batch D1 → sem estado parcial em crashes

  • Convergência mensurável: a matriz de mutação (T2) prova entidade a entidade

Negativas

  • Mais código no curto prazo: cada entidade ganha um command handler dedicado

  • Duplicação temporária (rollback convention) até o próximo release

  • events como auditoria, não replay: se no futuro for necessário time-travel, será preciso backfill ou migração adicional

Neutras

  • ROLE_WEIGHT move para @neemias/permissions (Fase 1) — é correção de bug, não decisão arquitetural nova

  • Church-events e onboarding entram no modelo ou permanecem CRUD explícito — decidir na Fase 4/4b conforme o aggregate boundary

Distribuído sob licença MIT.