Skip to content

Arquitetura

Visão geral

┌──────────────────────────────────────────────────┐
│              Frontend (React 19 + Vite)          │
│   TanStack Router → Componentes → Axios → API   │
└────────────────────┬─────────────────────────────┘
                     │ HTTPS / JWT / WebSocket

┌──────────────────────────────────────────────────┐
│           Backend (Spring Boot 3.5)              │
│                                                  │
│  Security Filters                                │
│  ApiKey → RobotRpc → TenantFilter → JWT          │
│                    ↓                             │
│  Controllers (REST, OpenAPI 3.1)                 │
│                    ↓                             │
│  Services (@Transactional)                       │
│                    ↓                             │
│  Repositories (JPA) + Flowable Engines           │
│  (BPMN, CMMN, DMN, Task, History)               │
│                                                  │
│  + NotificationService (WebSocket STOMP)         │
│  + RobotExecutor (Scheduler + atribui ao runner) │
│  + WebhookService (CRUD + HMAC-SHA256; ver nota) │
│  + AttachmentStorage (S3; filesystem só em dev)  │
│  + AgenticLlmCoreService (Spring AI + RAG)       │
└────────────────────┬─────────────────────────────┘

        ┌────────────┼────────────┐
        ▼            ▼            ▼
  ┌──────────┐ ┌──────────┐ ┌──────────┐
  │  public  │ │tenant_abc│ │tenant_xyz│
  │ (shared) │ │(isolado) │ │(isolado) │
  └──────────┘ └──────────┘ └──────────┘
        └────────────┴────────────┘

              ┌──────┴──────┐
              │ PostgreSQL 16│
              │  + pgvector  │
              └─────────────┘

O schema do tenant não guarda tudo o que é do tenant. As 53 tabelas ACT_* do Flowable — definições, instâncias, tarefas, jobs e histórico — vivem em public e são isoladas pelo discriminador tenant_id_, não pelo schema, e o mesmo vale para as tabelas por discriminador (ai_agents, robots, api_keys, …). Quais tabelas ficam em cada lugar está em Multi-tenancy.

Camadas da aplicação

Backend

Controller   — REST endpoint, anotações OpenAPI, sem lógica de negócio

Service      — lógica de negócio, @Transactional, validações

Repository   — interfaces JPA (Spring Data)
Engine       — Flowable BPMN/CMMN/DMN (via Services)

Entity       — modelos JPA mapeados ao banco

Regras:

  • Controller nunca acessa Repository diretamente
  • Services gerenciam todas as transações
  • Toda interação com Flowable passa pelo Service

Frontend

Routes (TanStack Router)

Pages (componentes de página)

Hooks (React Query — dados remotos) + Stores (Zustand — estado local)

lib/axios.ts (cliente HTTP com interceptors JWT + refresh)

Carregamento sob demanda

Cada rota é um chunk separado: o TanStackRouterVite roda com autoCodeSplitting, então o componente de página só é baixado quando você navega até ele. A tela de login carrega o shell da aplicação e mais nada — nem modelador BPMN/CMMN/DMN, nem visualizadores de diagrama, nem construtor de formulários, nem o copiloto. Enquanto um chunk de rota está a caminho o roteador mostra um indicador de carregamento, e um link sob o cursor já começa a ser buscado antes do clique.

Isso tem duas consequências práticas para quem mexe no código:

  • Um arquivo de rota exporta Route e mais nada. Qualquer outro export de runtime prende o módulo inteiro — e tudo que ele importa — no primeiro carregamento. Helper compartilhado entre duas rotas vai para lib/ ou -components/, nunca para a rota vizinha. Export de tipo pode ficar: tipo não sobrevive à compilação.
  • Os visualizadores de diagrama vêm sempre de @/components/LazyDiagramViewers. Importar BpmnViewer, CmmnViewer ou DmnViewer direto do componente traz bpmn-js/cmmn-js/dmn-js para o primeiro carregamento de todo mundo, inclusive de quem só abriu o login.

O npm run build termina medindo tudo que o dist/index.html referencia e falha se o total passar do teto definido em frontend/scripts/bundle-budget.mjs. O mesmo teto é reafirmado por src/__tests__/bundle-budget.test.ts. Subir o teto é uma decisão consciente: mude o número e explique o motivo em specs/frontend/architecture.md.

Filtros de segurança (Backend)

Os filtros são aplicados em cadeia a cada requisição, nesta ordem — ela está registrada em config/SecurityConfig.java e importa: o TenantFilter roda antes do JwtAuthFilter, de modo que o tenant já está no TenantContext quando a autenticação do usuário acontece.

OrdemFiltroResponsabilidade
1McpBasicAuthFilterAutentica o servidor MCP (/api/a/mcp) via HTTP Basic
2ApiKeyAuthFilterAutentica requisições via header X-API-Key
3RobotRpcFilterAutentica o canal RPC do sandbox de robôs (/api/internal/robot-rpc/**)
4TenantFilterLê o tenant do claim assinado no JWT e popula TenantContext (ThreadLocal). O header X-Tenant-ID não escolhe tenant
5JwtAuthFilterValida Bearer token JWT e popula o SecurityContext

Não existe um AuditLogFilter. O que alimenta os logs é o MDC, montado dentro dos dois filtros acima: o TenantFilter grava requestId e tenantId, o JwtAuthFilter grava userId. Procurar uma classe de auditoria dedicada é perda de tempo — quem quiser correlacionar requisições usa esses três campos nas linhas de log.

Componentes especiais

TenantAwareDataSource

AbstractRoutingDataSource que roteia cada conexão de banco para o schema correto baseado no TenantContext. Existe um pool HikariCP por nó e nenhum pool por tenant: o search_path é aplicado no empréstimo da conexão e a sessão é limpa na devolução, então o orçamento de conexões não cresce com o número de clientes. O pool por tenant foi removido em 2026-09-09 — ver Multi-tenancy.

RobotExecutorService e RobotSchedulerService

RobotSchedulerService dispara os robôs pela expressão cron, via ScheduledTaskRegistrar. RobotExecutorService grava a execução como PENDING e a entrega ao RunnerAssignmentService, que escolhe um runner cadastrado. A aplicação não abre JAR de robô: quem sobe o sandbox e executa o código é a máquina do runner, e é isso que mantém o código de um tenant fora do disco que guarda a configuração, as credenciais e os anexos dos outros. O sandbox fala com a plataforma pelo canal RPC em /api/internal/robot-rpc/**.

WebhookService

Cadastra webhooks por tenant, entrega o payload por POST e assina cada corpo com HMAC-SHA256 quando o webhook tem secret configurado — a assinatura vai no header X-FlowIa-Signature (sha256=...) e o evento em X-FlowIa-Event.

WebhookEventListener escuta o motor e traduz cinco eventos para os nomes documentados: instance.started, instance.completed, instance.failed, task.created e task.completed.

Antes de qualquer envio, WebhookEgressGuard confere o host do destino contra a lista que o tenant declarou (tenants.webhook_allowed_hosts), usando o mesmo EgressAllowList da chamada HTTP dos agentes de IA. Destino fora da lista é recusado ao salvar o webhook, com o host na mensagem, e a entrega já enfileirada termina como BLOCKED — status terminal, distinto de FAILED: FAILED é o destino dizendo não, BLOCKED é a plataforma. O SsrfValidator roda depois da lista, então declarar localhost não abre um alvo interno. app.webhooks.egress.default-policy decide o que significa uma lista vazia — allow mantém instalação existente funcionando, deny é o que uma instalação nova deve rodar. Ver specs/automation/webhook-egress-allowlist.md.

task.overdue não existe

Vencimento de prazo não é evento do motor — nada acontece no instante em que a data passa. Entregar esse evento exige uma varredura periódica sobre as tarefas abertas, com estado para não reenviar a cada passagem. Não está implementado, e é melhor dizer isso do que listá-lo ao lado dos outros cinco.

O listener apenas grava a entrega; quem envia é o job. A chamada HTTP não acontece dentro da transação do processo, e isso é deliberado: uma instância concluindo seguraria a conexão de banco durante a chamada ao servidor de outra empresa, inclusive o timeout dela. Era a mesma forma de falha da chamada ao provedor de IA dentro da transação, que serializava o tenant inteiro e, sob concorrência, esgotava o pool — e que saiu da transação em 2026-09-09.

A consequência prática é que a entrega não é imediata: no pior caso, um minuto. E se a transação do processo desfizer, a entrega desfaz junto — instância que não iniciou não deve anunciar que iniciou.

O retry não é exponencial. Um job @Scheduled roda a cada 60 segundos, varre as entregas em PENDING e RETRYING e reenvia cada uma. O intervalo é sempre o mesmo minuto, e ao atingir 3 tentativas a entrega é marcada como FAILED e nunca mais é retomada.

Concorrência das etapas de IA

A etapa de IA saiu da thread do motor. Desde 2026-09-09 no BPMN e 2026-09-10 no CMMN ela grava uma reserva, devolve a thread e a transação, e a chamada ao modelo acontece numa pool própria (aiStepTaskExecutor, em AiStepExecutorConfig). Quando a resposta chega, a conclusão volta pelo motor — triggerAsync no BPMN, o trigger do plan item no CMMN. Enquanto o provedor pensa, a etapa não ocupa thread de job do Flowable nem mantém transação ou conexão de banco aberta.

O teto sustentado de etapas de IA simultâneas passou a ser o core-pool-size dessa pool: 8 por instalação (AI_STEP_CORE_SIZE), com fila de 100 (AI_STEP_QUEUE_CAPACITY) e espera máxima pelo provedor de 120 s (AI_STEP_PROVIDER_TIMEOUT_SECONDS). Fila cheia não estaciona a instância em silêncio: a reserva é encerrada como falha e a etapa aparece para reprocesso. O lease de uma reserva é o dobro do tempo de provedor, e um ceifador periódico refaz a etapa cujo nó caiu.

Em vazão — a forma que uma conversa comercial precisa:

passos por minuto = core-pool-size da pool de IA × 60 ÷ latência da chamada em segundos

Com o padrão de hoje (8) e uma chamada de IA de cerca de 20 s, a aritmética dá 24 etapas por minuto, 1.440 por hora, por instalação. É conta, não medição: a bancada de 2026-09-03 que produziu os números publicados rodou com o desenho antigo, de 2 vagas, e está em specs/core/ai-step-concurrency.md. A forma nova está em specs/ai/ai-step-off-the-job-thread.md e specs/ai/cmmn-ai-step-off-the-command-thread.md. Antes de prometer vazão a alguém, meça com a sua carga.

O pool de jobs do Flowable continua sendo um único bean ThreadPoolTaskExecutor (applicationTaskExecutor), definido em FlowableTaskExecutorConfig e compartilhado pelos dois motores, com core-pool-size 2 (FLOWABLE_EXECUTOR_CORE_SIZE) e max-pool-size 3. Ele não é mais o teto da IA, mas continua sendo o teto de todo o resto — timers, continuações assíncronas e o job que conclui a etapa de IA depois que a resposta chegou.

Elevar o número de threads de job em um único nó não é capacidade grátis: um job pode segurar duas conexões do pool compartilhado (spring.datasource.hikari.maximum-pool-size, 10 por padrão) por causa do REQUIRES_NEW na dedução de cota. O piso é exatamente max-pool-size × 2 + 1 = 7 — e vale também para app.tenant.pool.maximum-pool-size (7), que é o teto de um tenant dentro daquele pool. Subir FLOWABLE_EXECUTOR_MAX_SIZE obriga a subir os dois junto, sob pena de o tenant parar de concluir etapa. Detalhes de onde o número vem e o comportamento configurável para fila cheia (queue-full-policy, ABORT ou CALLER_RUNS) estão em specs/core/ai-step-concurrency.md e specs/ai/quota-transaction-boundary.md.

O processo publicado antes da mudança continua no caminho antigo

No BPMN a etapa só reserva e volta quando o XML publicado carrega flowable:triggerable="true", que o modelador escreve ao definir a implementação da tarefa como ✨ Agente de IA. Definição publicada antes desta versão continua chamando o provedor na thread que chegou até ela — abrir a tarefa no modelador, escolher ✨ Agente de IA de novo e republicar é o que a move para a forma nova. Num caso (CMMN) não há atributo nenhum a escrever: o comportamento vem da própria classe do delegate e vale para todos os casos assim que a versão sobe.

Job abandonado pelo motor

Quando a falha de um job é a própria ausência de conexão com o banco, o tratador de erro do Flowable falha pelo mesmo motivo que o trabalho que ele ia tratar: a exceção escapa do executor e a linha em act_ru_job fica com o lock que a aquisição escreveu. Antes isso era invisível — sem log que nomeasse o job, sem linha de dead-letter, e a única forma de descobrir era consultar act_ru_job à mão.

Hoje esse escape é capturado no ponto que ainda sabe de que job se trata e o job é devolvido à fila com uma tentativa a menos, carregando o motivo. Na última tentativa ele vai para a fila de dead-letter com esse mesmo motivo — ou seja, aparece em GET /a/instances/failed-jobs e no acompanhamento por etapa (JOB_RETRYING enquanto tem tentativa, JOB_FAILED depois), como qualquer outra falha. Além disso sai uma linha de log em nível ERROR com job, tenant, handler e instância, e o contador flowi.job.abandoned (tags tenant, handler, outcome) sobe mesmo quando o banco é justamente o que está fora — é nele que um alerta deve se apoiar. outcome=stuck significa que nem a devolução conseguiu escrever: aí o job só volta quando o lock_exp_time_ dele expirar, e o log é a única evidência de que ele existe até lá. Detalhes em specs/core/abandoned-job-recovery.md.

Coordenação entre instâncias (lease de job)

Com mais de uma instância da aplicação rodando contra o mesmo banco, todo @Scheduled dispararia em cada nó — o retry de webhook acima entregaria o mesmo payload ao cliente uma vez por nó, e um robô com CRON rodaria em duplicidade. Isso é evitado por um lease em banco (tabela job_leases, schema compartilhado): cada tarefa disputa a posse de um nome fixo por um período curto antes de rodar, só o nó que já é dono renova, e um lease expirado (nó caído) é assumido por outro na tentativa seguinte. Cobre os @Scheduled de limpeza e retry e o disparo de CRON por robô em RobotSchedulerService.

O limitador de login era a exceção, contando requisições no heap do próprio nó. Deixou de ser em 2026-08-28: ele e o orçamento de cada chave de API contam numa tabela compartilhada (rate_limit_counters), porque um limite por nó entrega N vezes o número que a tela promete assim que alguém sobe a segunda réplica. Duas outras coisas que viviam no heap de um pod também saíram — eventos em tempo real e a migração de tenant no boot — e estão descritas em specs/core/cluster-fanout.md.

A exceção que sobra é deliberada e ao contrário: a reconciliação de delegates roda sem lease, em todo nó, porque o que ela repara é estado por nó (specs/core/delegate-convergence.md).

Papel do nó (APP_NODE_ROLE)

O lease acima é também onde o papel do nó é aplicado. APP_NODE_ROLE escolhe entre all (o padrão, que faz tudo, como sempre fez), api e worker:

PapelHTTPAdquire job do FlowableTarefa agendada de cluster e CRON de robôFlyway compartilhado no bootTeto por tenant padrão
alltudosimno nó eleitosim7
apitudonãonãonão3
worker/actuatorsimno nó eleitosim7

Um nó api continua criando job — iniciar instância, concluir tarefa e gravar variável escrevem em ACT_RU_JOB — ele só nunca adquire. E "não roda tarefa agendada" quer dizer tarefa de cluster: num nó api, JobLeaseService#tryAcquire nega o lease na hora, sem ir ao banco, e com isso toda tarefa agendada que disputa lease vira uma volta em falso. As três que não disputam lease continuam rodando lá de propósito, porque reparam ou publicam estado daquele nó — a reconciliação de delegates, a amostragem de saturação da fila de IA e o heartbeat de membership do nó. ScheduledWorkIsLeasedOrPerNodeTest reprova um @Scheduled novo que não faça nem uma coisa nem outra.

O papel é um valor só, e todo o resto que ele decide é default: FLOWABLE_JOBS_ENABLED e TENANT_POOL_MAX_SIZE explícitos continuam ganhando dele. Papel escrito errado recusa a subir, em vez de virar all e servir sob um papel que não é o configurado.

Teto de conexões impresso no boot

NodeConnectionBudget calcula, a cada subida, quanto a instalação pede ao banco: (compartilhado + 1) × pods esperados. O parêntese é o custo de um pod — cada pod é uma JVM com o próprio pool compartilhado — e o +1 é a conexão do barramento de cluster. A linha sai sempre, com o papel do nó e a decomposição (... = N por pod × M pods = T), e não só com o total, porque cada fator tem uma alavanca diferente; passando de APP_DATABASE_MAX_CONNECTIONS vira aviso com o excedente e as alavancas que restam, e com APP_DATABASE_BUDGET_ENFORCE=true o nó recusa subir. O número é pior caso, não consumo atual.

Não há parcela por tenant. Ela saiu junto com o pool por tenant em 2026-09-09, então o cliente seguinte não move este número e o que estoura o teto é o pod. APP_DATABASE_EXPECTED_TENANTS continua sendo impresso ao lado da conta, justamente para mostrar que não entra nela. Com APP_NODE_AFFINITY_ENABLED=true a linha diz que a afinidade não compra conexão nenhuma — não sobrou parcela por tenant para ela deixar de multiplicar — e que o que ela ainda compra é posse de job e localidade de plano. Ver specs/core/job-worker-nodes.md, specs/core/connection-pool-budget.md e specs/core/tenant-node-affinity.md.

NotificationService

Usa Spring WebSocket (STOMP) para enviar notificações em tempo real ao frontend. Cada usuário se inscreve em um canal pessoal.

O broker é em memória, mas nada publica direto nele: toda mensagem passa por RealtimeMessagePublisher, que a manda pelo LISTEN/NOTIFY do Postgres, e cada nó entrega às suas próprias sessões. É o que faz um evento publicado num pod chegar ao navegador ligado em outro, sem broker externo e sem afinidade de sessão. Ver specs/core/cluster-fanout.md.

FormVersionLockListener

Listener Flowable do tipo TASK_CREATED. Quando o engine cria uma tarefa, este listener:

  1. Lê o formKey definido no modelo BPMN/CMMN
  2. Chama FormService.resolvePublishedVersion(formKey) para obter a versão publicada (ou a mais recente se nenhuma foi publicada)
  3. Armazena o UUID da versão na variável local da tarefa: _form_version_id

Isso garante que a tarefa sempre exiba o formulário com o schema da versão que estava vigente em sua criação. isFailOnException = false — uma falha ao travar a versão não impede a criação da tarefa.

AiClientFactory e PgVector Dinâmico

O Flowi Agentic integra nativamente o Spring AI. Para suportar o fato de que a plataforma permite alterar as configurações de provedor e dimensões do modelo de embeddings via banco (Tabela rag_global_config), o Vector Database (PgVectorStore com indexação HNSW em PostgreSQL) é recriado on-the-fly.

O AiClientFactory lê a configuração e instancia o VectorStore dinamicamente sob demanda. Quando uma alteração estrutural de dimensão ocorre, o banco deforma e auto-reconstrói a tabela vector_store, invalidando vetores antigos que precisariam ser gerados através do endpoint de reindexação.

A fábrica é também o único lugar que monta cliente de modelo: é nela que o advisor de contabilização é anexado ao ChatClient e que o modelo de embeddings ganha a medição. Cliente montado por fora produz chamada que gasta token e não aparece no relatório do tenant. EveryModelCallIsMeteredTest reprova essa montagem por fora e reprova também a invocação de modelo cujo método não abre e limpa o contexto de uso.

Flowi Agentic — Plataforma de Gestão de Processos com IA