DEV Community

Transactional Outbox no JurisOS: Resiliência Local-First e Criptografia End-to-End com Elixir & BEAM

Transactional Outbox no JurisOS: Resiliência Local-First e Criptografia End-to-End com Elixir & BEAM

Construir softwares para escritórios de advocacia exige um nível rigoroso de confiabilidade. Secretárias e advogados operam sob alta pressão de prazos processuais e não podem tolerar telas congeladas por latência de rede ou perdas de rascunhos de minutas quando a conexão cai. O JurisOS foi arquitetado sob o paradigma Local-First, garantindo latência zero (< 1ms) nas interações locais da interface, combinado com sincronização resiliente em segundo plano.

A infraestrutura do aplicativo combina Elixir Desktop e Burrito para empacotar o runtime da BEAM em um binário nativo, utilizando SQLite (com SQLCipher) para persistência local, Mnesia para estado distribuído leve na camada local e Tailscale VPN para o túnel de transporte seguro.


1. O Desafio do Local-First

Sistemas jurídicos legados dependem de conexões síncronas diretas com instâncias centralizadas de bancos de dados (como PostgreSQL na AWS). Em redes instáveis — escritórios com Wi-Fi saturado, tribunais com sinal precário ou tribunais remotos —, essa abordagem resulta em falhas de requisição, timeouts e perda de dados não salvos.

O modelo Local-First do JurisOS inverte essa lógica: o cliente lê e escreve primariamente em seu armazenamento local integrado. O banco de dados central na nuvem deixa de ser um ponto de falha síncrono e passa a ser um destino de eventual consistência. Para viabilizar essa arquitetura sem corromper o estado global, o ecossistema BEAM provê o isolamento de processos e a concorrência necessária para gerenciar filas locais de sincronização sem bloquear o fluxo de trabalho do usuário.


2. O Padrão Transactional Outbox com Ecto

O maior perigo em arquiteturas híbridas (Local + Nuvem) é o Dual-Write Problem: atualizar o banco de dados local e, em seguida, disparar uma requisição de rede para a mensageria ou nuvem. Se a aplicação cair após a escrita local mas antes do envio da rede, o evento de sincronização é perdido, gerando divergência permanente entre o cliente e o servidor central.

Para eliminar essa classe de erro, o JurisOS implementa o Padrão Transactional Outbox. A entidade de negócio (ex: Contrato, CartaoKanban) e o evento de sincronização correspondente são gravados na mesma transação ACID do SQLite local usando Ecto.Multi.

defmodule JurisOS.Contratos.Context do
  import Ecto.Query
  alias JurisOS.Repo
  alias JurisOS.Contratos.Contrato
  alias JurisOS.Sync.OutboxEntry

  def criar_contrato(attrs) do
    Ecto.Multi.new()
    |> Ecto.Multi.insert(:contrato, Contrato.changeset(%Contrato{}, attrs))
    |> Ecto.Multi.insert(:outbox, fn %{contrato: contrato} ->
      payload = %{
        event_type: "contrato_criado",
        aggregate_id: contrato.id,
        data: Map.from_struct(contrato)
      }

      OutboxEntry.changeset(%OutboxEntry{}, %{
        event_id: JurisOS.Snowflake.generate_id(),
        payload: payload,
        status: "pending"
      })
    end)
    |> Repo.transaction()
  end
end

Enter fullscreen mode Exit fullscreen mode

Se a transação falhar, nada é persistido. Se for bem-sucedida, o evento na tabela sync_outbox está garantido no disco e será processado pelo motor de sincronização assim que houver conectividade.


3. Criptografia & Privacy by Design (Cloak.Ecto + LGPD/GDPR)

Manter dados jurídicos confidenciais (CPFs, laudos médicos, estratégias de litígio, sigilo bancário) armazenados em SQLite no disco local expõe o escritório a riscos severos em caso de furto, perda ou acesso não autorizado à estação de trabalho.

O JurisOS resolve essa vulnerabilidade integrando o Cloak.Ecto diretamente nos schemas do Ecto. O payload JSON armazenado na outbox e as tabelas sensíveis de negócio passam por criptografia AES-256-GCM antes de serem gravadas no arquivo do SQLite.

defmodule JurisOS.Vault do
  use Cloak.Vault, otp_app: :juris_os

  @impl true
  def init(config) do
    config =
      Keyword.put(config, :aes_gcm,
        key: Base.decode64!(System.fetch_env!("JURIS_ENCRYPTION_KEY"))
      )

    {:ok, config}
  end
end

defmodule JurisOS.Sync.EncryptedBinary do
  use Cloak.Ecto.Binary, vault: JurisOS.Vault
end

defmodule JurisOS.Sync.OutboxEntry do
  use Ecto.Schema
  import Ecto.Changeset

  schema "sync_outbox" do
    field :event_id, :string
    field :payload, JurisOS.Sync.EncryptedBinary
    field :status, :string, default: "pending"
    timestamps()
  end

  def changeset(entry, attrs) do
    entry
    |> cast(attrs, [:event_id, :payload, :status])
    |> validate_required([:event_id, :payload, :status])
  end
end

Enter fullscreen mode Exit fullscreen mode

Com essa estratégia, mesmo que o arquivo do banco de dados seja extraído do disco do laptop da secretária, os dados permanecem completamente indecifráveis sem a chave mestra isolada no ambiente de execução seguro.


4. O Motor de Sincronização (GenServer + libcluster + WebSockets)

O transporte dos eventos outbox acumulados é gerido por um GenServer especializado que consome a fila local em ordem FIFO (First-In, First-Out).

Para garantir segurança máxima sem expor portas públicas na internet ou depender de certificados complexos, o tráfego de rede flui exclusivamente pelo túnel privado da Tailscale VPN (100.64.0.0/10), conectando o cliente desktop diretamente ao nó mestre na AWS.

+-------------------------------------------------------------+
| JurisOS Desktop (Cliente Local)                             |
|  [ SQLite / SQLCipher ] ---> [ Outbox GenServer ]           |
+------------------------------|------------------------------+
                               | (Túnel Criptografado Tailscale)
                               v
+-------------------------------------------------------------+
| Nó Mestre AWS (Cloud Backend)                               |
|  [ Phoenix Endpoint ] ---> [ Processador de Eventos (ACK) ] |
+-------------------------------------------------------------+

Enter fullscreen mode Exit fullscreen mode

O Ciclo de Vida do Evento

  1. Pending: O evento é gerado e salvo na outbox local de forma transacional.
  2. Em trânsito: O GenServer lê o lote, decifra o payload em memória, envia via WebSocket seguro pela Tailscale.
  3. ACK (Acknowledgement): O Nó Mestre AWS valida o evento, persiste no PostgreSQL central e retorna um ACK criptografado.
  4. Synced: O worker local recebe o ACK e marca o registro na outbox como synced (ou o remove após retenção de auditoria).
  5. Propagação Real-Time: O nó central utiliza o Phoenix.PubSub para despachar atualizações instantâneas para as demais estações de trabalho do escritório.

5. Garantias de Integridade e Resolução de Conflitos

Sistemas offline enfrentam o problema clássico de concorrência: duas pessoas editando o mesmo documento ou lançando valores em paralelo sem conexão mútua. O JurisOS aplica três camadas de proteção arquitetural:

  • Snowflake IDs (64 bits - layout 41-10-12): Cada estação cliente possui um worker_id único configurado no build do aplicativo. As chaves primárias geradas offline contêm timestamp de alta precisão e o identificador do nó, eliminando 100% de colisões de chaves primárias mesmo quando múltiplos advogados geram registros simultaneamente sem rede.
  • Optimistic Locking (sync_version): Tabelas críticas de lançamentos financeiros e integração com ERP SAP possuem uma coluna de versão. Atualizações enviadas com uma versão desatualizada são rejeitadas pelo servidor, forçando a reavaliação do estado atual.
  • Branching de Rascunhos (versoes_minuta): Edições concorrentes em minutas contratuais nunca sofrem overwrite cego. O sistema cria ramificações locais isoladas de rascunhos que são submetidas a um Quality Gate (/qualidade/revisao) para fusão manual ou assistida por IA.

6. Developer Experience (DX) & Testabilidade

Um dos grandes diferenciais da stack BEAM é a capacidade de isolar estados em testes unitários. A suíte de testes do JurisOS executa inteiramente em sub-segundos utilizando instâncias efêmeras de SQLite em memória (:memory:) e stubs locais para os serviços de rede.

defmodule JurisOS.ContratosTest do
  use JurisOS.DataCase, async: true
  alias JurisOS.Contratos.Context

  test "criar_contrato grava entidade e insere outbox atomicamente" do
    attrs = %{titulo: "Prestação de Serviços Jurídicos", valor: 15000.00}

    assert {:ok, %{contrato: contrato, outbox: outbox}} = Context.criar_contrato(attrs)

    assert contrato.id != nil
    assert outbox.status == "pending"
    assert is_binary(outbox.payload) # Payload cifrado no disco
  end
end

Enter fullscreen mode Exit fullscreen mode

A ausência de dependências externas como containers Docker do PostgreSQL ou emuladores de AWS acelera o ciclo de feedback do desenvolvedor (mix test), permitindo rodar centenas de testes de resiliência transacional em frações de segundo.

Top comments (0)