# Arquitetura (/arquitetura) ```mermaid flowchart LR client[Cliente HTTP] --> api[api] api --> pg[(PostgreSQL 18)] api --> valkey[(Valkey 8)] workers[workers] --> pg workers --> valkey workers <--> sqs[[ElasticMQ / SQS]] migrator[migrator] --> pg ``` ## Executáveis [#executáveis] Cada executável é uma imagem própria, gerada do mesmo `Dockerfile` (alvos `runtime`, `workers` e `migrator`). | Executável | O que faz | Porta | | --- | --- | --- | | `api` | HTTP (Hono, com OpenAPI). Grava efeitos e mensagens do outbox na mesma transação; nunca fala com a fila. | 3000 | | `workers` | Consome as filas listadas em `QUEUES`, roda tasks e assinantes de eventos; um deles também roda o relay do outbox. | 8081 (`/healthz`) | | `migrator` | Aplica as migrations pendentes e roda os seeders padrão (`deploy`), depois termina. | nenhuma | A API não aplica migrations ao subir. A API e os workers carregam o nome da migration mais nova do próprio build e ficam **não prontos** até o banco conter essa migration: `/ready` responde 503 com a migration pendente e os workers não consomem. Assim uma versão nova nunca serve sobre um schema antigo, mesmo que o migrator rode depois dela. ## Serviços [#serviços] | Serviço | Papel | | --- | --- | | PostgreSQL 18 | Fonte da verdade: dados de domínio, outbox, idempotência e auditoria das execuções. | | Valkey 8 | Fala o protocolo Redis (o serviço continua chamado `redis`, assim como `REDIS_URL`). Portão de idempotência, cache do status de runs e sinal para acordar o relay. Nada nele é fonte da verdade. | | ElasticMQ | Fila compatível com SQS usada localmente e em staging: quatro filas (`critical`, `default`, `bulk`, `maintenance`) e suas dead-letter queues, em memória. | Os workers usam a API do SQS, então o mesmo código fala com o SQS da AWS trocando endpoint e credenciais (`MONALISA_SQS_*`). ## HTTP [#http] - `GET /health`: o processo está vivo. - `GET /ready`: PostgreSQL e Redis disponíveis e o schema na migration esperada; responde 503 com um motivo público quando não está pronto. As duas respostas trazem `latestMigration`. - `GET /task-runs/{runId}`: status de uma execução em segundo plano do tenant de quem chama (rota protegida). - `/docs` e `/openapi.json`: referência pública gerada das rotas. Respostas de sucesso têm `success`, `data` e `meta`; erros têm `success: false` e `error` com `code` e `message`. `Accept-Language` escolhe entre inglês (padrão) e português. Rotas protegidas respondem 401 enquanto a autenticação é reconstruída (ver [Autenticação](/autenticacao)). Cabeçalhos como `x-tenant-id` ou `x-user-id` nunca estabelecem identidade nem autoridade. ## Organização do código [#organização-do-código] | Diretório | Responsabilidade | | --- | --- | | `apps/api` | Composição da API, rotas e validação de transporte | | `apps/workers` | Tasks (`*.task.ts`), assinantes (`*.handler.ts`) e o loop de consumo | | `apps/migrator` | Comandos de migrate/deploy, geração de migrations, seeders e o baseline | | `packages/` | Entities, SQL de constraints, regras, seeders e fixtures de teste de cada domínio | | `framework/*` | DI, configuração, HTTP, ORM (MikroORM 7), mensageria, idempotência e jobs | | `infra/dokploy` | Layout de staging no Dokploy e os comandos `dokploy:apply` / `dokploy:deploy` | A persistência usa MikroORM com registro explícito das entities. Cada operação abre seu próprio EntityManager e aplica o contexto do ator (tenant, usuário ou processo) dentro da mesma transação do PostgreSQL. O schema é controlado só por migrations; a API nunca sincroniza schema ao subir. # Autenticação (/autenticacao) O login ainda está em reconstrução: nenhuma rota emite sessão hoje e **toda rota protegida responde 401**. Esta página descreve o que já está no código (NOVA-118), não o desenho do login. ## Estado atual [#estado-atual] - Os endpoints antigos de login, sessão e refresh foram removidos. - Toda rota protegida passa por `AuthenticateSession`, que pede a um `SessionResolver` para transformar o token da sessão em sessão. Em produção, o resolver ligado não resolve nada, então toda rota protegida responde 401. - Rotas públicas continuam funcionando: `/health`, `/ready`, `/docs` e `/openapi.json`. - Cabeçalhos como `x-tenant-id`, `x-user-id` e `x-system` nunca estabelecem identidade nem autoridade. - Os testes da API autenticam com um resolver falso (`FakeSessionResolver`), que apresenta o token como Bearer ou como cookie. ## De onde vem o token da sessão [#de-onde-vem-o-token-da-sessão] A API aceita o token de duas fontes. O contrato OpenAPI declara os dois esquemas, `Bearer` e `SessionCookie`, como alternativas em toda rota protegida. | Fonte | Para quem | | --- | --- | | Cookie `__Host-monalisa_session` | O navegador: o cookie é `HttpOnly`, então o site nunca guarda o token | | `Authorization: Bearer ` | Os demais clientes | **O Bearer vence.** Regras de leitura: - O esquema `Bearer` é reconhecido sem diferenciar maiúsculas; token vazio ou com mais de uma parte recebe 401 antes de qualquer consulta. - Um `Authorization` com outro esquema (por exemplo, Basic acrescentado por um proxy) não é fonte de sessão: é ignorado, e o cookie continua autenticando. - Se a requisição manda Bearer e cookie com tokens diferentes, recebe 401: os dois nomeiam sessões diferentes. - Uma credencial grande demais recebe 401. Tokens nunca aparecem em mensagens de erro, detalhes ou logs. ## O cookie [#o-cookie] Por padrão o cookie é **host-only**: `__Host-monalisa_session`, com `HttpOnly; Secure; SameSite=Lax; Path=/` e sem `Domain`. O prefixo `__Host-` faz o navegador recusar qualquer cópia com `Domain` ou com `Path` mais estreito, então um subdomínio vizinho não consegue plantar um cookie que esconda o verdadeiro. O site e a API ficam sob o mesmo site registrável, então o `fetch` com credenciais do site já leva o cookie host-only da API. - **Duplicatas.** Um cabeçalho `Cookie` que traz o nome do cookie de sessão mais de uma vez recebe 401, porque uma cópia plantada venceria em silêncio. - **`SESSION_COOKIE_DOMAIN` (opcional).** Só para um consumidor do lado do servidor confirmado num host vizinho (como SSR que repassa o cookie). O cookie passa a se chamar `__Secure-monalisa_session`, com esse `Domain`, e chega a todos os subdomínios. O valor precisa ser um host simples, em minúsculas, com pelo menos dois rótulos e que não seja um sufixo público conhecido; senão a API não sobe. - A definição do cookie (nome e atributos) existe num lugar só, e o esquema `SessionCookie` do OpenAPI tira o nome dela. ## Escritas com cookie exigem origem exata [#escritas-com-cookie-exigem-origem-exata] Um `POST`, `PUT`, `PATCH` ou `DELETE` autenticado pelo cookie precisa trazer um `Origin` (ou, sem ele, um `Referer`) cuja origem esteja **exatamente** listada em `CORS_ORIGINS`. Sem isso, recebe 403 antes de a sessão ser resolvida. - Requisições com Bearer e requisições `GET`, `HEAD` e `OPTIONS` não passam por essa checagem. Uma página estranha não consegue mandar `Authorization`, porque o preflight de CORS dela não recebe `Access-Control-Allow-Origin`. - Com `CORS_ORIGINS` vazio (o padrão), toda escrita autenticada por cookie recebe 403. Uma implantação em que o site e a API dividem o mesmo host também precisa listar a própria origem. - O site não deve contar com `Referrer-Policy: no-referrer` para clientes que omitem `Origin`. ## CORS [#cors] Ambientes que usam o cookie definem `CORS_CREDENTIALS=true` e listam as origens exatas do site em `CORS_ORIGINS`; a mesma lista alimenta o CORS e a checagem de origem. A API não sobe com `CORS_CREDENTIALS=true` e `CORS_ORIGINS` vazio, e recusa `*` ou qualquer origem que não seja exata, em qualquer ambiente. Staging ainda usa `CORS_CREDENTIALS=false` até o login emitir cookies. ## O que já existe no banco [#o-que-já-existe-no-banco] O schema de identidade continua no baseline: `users`, `user_logins` (por tenant), `platform_logins` (operador de plataforma, sem tenant), `user_credentials`, `sessions`, `refresh_tokens`, `session_memberships`, `session_membership_brokers` e `session_contexts`. Nenhuma rota grava sessões hoje. O primeiro operador de plataforma é criado pelo [bootstrap](/migrations/seeders#o-bootstrap-da-plataforma), mas ainda não tem como fazer login. Ver [Identity](/modelo-de-dados/identity) para as tabelas. # Execuções e auditoria (/background/execucoes) Cada execução tem um estado atual (`task_runs` ou `platform_task_runs`) e uma trilha append-only (`task_run_events` ou `platform_task_run_events`). As regras que o banco impõe sobre elas estão em [Auditoria das execuções](/garantias/auditoria-de-execucoes). ## Ciclo de vida [#ciclo-de-vida] ```mermaid stateDiagram-v2 [*] --> queued: dispatch / publish queued --> running: worker recebe running --> succeeded running --> failed queued --> skipped: agendada com overlap skip e execução anterior ainda ativa running --> retrying: falha transitória retrying --> running: nova entrega retrying --> failed: recebimentos esgotados running --> queued: liberada no desligamento ``` A trilha registra cada passo: `queued`, `sent`, `received`, `started`, `progress`, `retry_scheduled`, `succeeded`, `failed`, `skipped`, `dead_lettered`, `released_on_shutdown` e `redriven`. ## Status pela API [#status-pela-api] `GET /task-runs/{runId}` devolve o status de uma run do tenant de quem chama e exige a permissão `access.task_runs.read`. A leitura vai primeiro ao Valkey e, numa falta, ao PostgreSQL. Toda transição commitada remove a cópia em cache; uma run ainda em andamento fica em cache por 5 s, e uma run encerrada, por um dia. Enquanto a autenticação é reconstruída, a rota responde 401 como as outras rotas protegidas. ## Tabelas [#tabelas] {/* gen:task_runs */} #### `task_runs` [#task_runs] Estado atual de cada execução de tenant, com o mesmo `run_id` do outbox e do envelope; nunca é apagada. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `run_id` | `uuid` | não | | | `tenant_id` | `bigint` | não | | | `name` | `text` | não | | | `queue` | `text` | não | | | `trigger` | `text` | não | | | `actor_kind` | `text` | não | | | `actor_label` | `text` | não | | | `correlation_id` | `uuid` | não | | | `causation_id` | `uuid` | não | | | `status` | `text` | não | | | `attempts` | `integer` | não | padrão `0` | | `progress` | `smallint` | não | padrão `0` | | `outcome` | `jsonb` | não | padrão `'{}'::jsonb` | | `queued_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `started_at` | `timestamptz` | sim | | | `finished_at` | `timestamptz` | sim | | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `tenant_id` → `tenants.id` Referenciada por: `task_run_events`, `task_run_user_actors`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `task_runs_actor_kind_check` | `CHECK ((actor_kind IN ('user', 'system')))` | | CHECK | `task_runs_actor_label_check` | `CHECK (((length(actor_label) >= 1) AND (length(actor_label) <= 128)))` | | CHECK | `task_runs_attempts_check` | `CHECK ((attempts >= 0))` | | CHECK | `task_runs_check` | `CHECK (((status IN ('succeeded', 'failed', 'skipped')) = (finished_at IS NOT NULL)))` | | CHECK | `task_runs_name_check` | `CHECK ((name ~ '^[a-z][a-z0-9._-]{1,63}$'))` | | CHECK | `task_runs_outcome_check` | `CHECK (((jsonb_typeof(outcome) = 'object') AND (octet_length((outcome)::text) <= 2048)))` | | CHECK | `task_runs_progress_check` | `CHECK (((progress >= 0) AND (progress <= 100)))` | | CHECK | `task_runs_queue_check` | `CHECK ((queue IN ('critical', 'default', 'bulk', 'maintenance')))` | | CHECK | `task_runs_status_check` | `CHECK ((status IN ('queued', 'running', 'retrying', 'succeeded', 'failed', 'skipped')))` | | CHECK | `task_runs_trigger_check` | `CHECK ((trigger IN ('dispatch', 'event', 'redrive')))` | | PK | `task_runs_pkey` | `PRIMARY KEY (run_id)` | | Constraint trigger | `task_runs_user_actor_present` | adiada até o commit (`DEFERRABLE INITIALLY DEFERRED`) | | UNIQUE | `task_runs_run_id_tenant_id_key` | `UNIQUE (run_id, tenant_id)` | | Trigger | `task_runs_identity_fixed` | só colunas de ciclo de vida podem mudar | | Trigger | `task_runs_never_deleted` | recusa `DELETE` (e `UPDATE`, nas tabelas de origem e ator) | | Trigger | `task_runs_no_truncate` | recusa `TRUNCATE` | **Fora do banco** - `actor_kind = 'platform_operator'` ainda não existe: runs de operador de plataforma são recusadas até haver tabela de origem própria. {/* /gen */} {/* gen:task_run_user_actors */} #### `task_run_user_actors` [#task_run_user_actors] O usuário e a membership de uma run disparada por pessoa (`actor_kind = 'user'`). Obrigatória na mesma transação. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `run_id` | `uuid` | não | | | `tenant_id` | `bigint` | não | | | `user_id` | `bigint` | não | | | `membership_id` | `bigint` | não | | **Chaves estrangeiras** - `(membership_id, user_id, tenant_id)` → `memberships (id, user_id, tenant_id)` (mesmo tenant) - `(run_id, tenant_id)` → `task_runs (run_id, tenant_id)` (mesmo tenant) - `user_id` → `users.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `task_run_user_actors_pkey` | `PRIMARY KEY (run_id)` | | Trigger | `task_run_user_actors_never_deleted` | recusa `DELETE` (e `UPDATE`, nas tabelas de origem e ator) | | Trigger | `task_run_user_actors_no_truncate` | recusa `TRUNCATE` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. {/* /gen */} {/* gen:task_run_events */} #### `task_run_events` [#task_run_events] Trilha append-only de uma run de tenant: cada transição (`queued`, `sent`, `started`, `retry_scheduled`…), com tentativa, contagem de recebimentos e worker. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `run_id` | `uuid` | não | | | `tenant_id` | `bigint` | não | | | `event` | `text` | não | | | `attempt` | `integer` | não | | | `receive_count` | `integer` | não | padrão `0` | | `queue` | `text` | não | | | `worker_instance` | `text` | não | | | `error_code` | `text` | não | padrão `''` | | `error_type` | `text` | não | padrão `''` | | `detail` | `jsonb` | não | padrão `'{}'::jsonb` | | `occurred_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(run_id, tenant_id)` → `task_runs (run_id, tenant_id)` (mesmo tenant) **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `task_run_events_attempt_check` | `CHECK ((attempt >= 0))` | | CHECK | `task_run_events_detail_check` | `CHECK (((jsonb_typeof(detail) = 'object') AND (octet_length((detail)::text) <= 2048)))` | | CHECK | `task_run_events_event_check` | `CHECK ((event IN ('queued', 'sent', 'received', 'started', 'progress', 'retry_scheduled', 'succeeded', 'failed', 'skipped', 'dead_lettered', 'released_on_shutdown', 'redriven')))` | | CHECK | `task_run_events_receive_count_check` | `CHECK ((receive_count >= 0))` | | CHECK | `task_run_events_worker_instance_check` | `CHECK (((length(worker_instance) >= 1) AND (length(worker_instance) <= 128)))` | | PK | `task_run_events_pkey` | `PRIMARY KEY (id)` | | Trigger | `task_run_events_append_only` | trilha append-only: recusa `UPDATE` e `DELETE` | | Trigger | `task_run_events_no_truncate` | recusa `TRUNCATE` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. {/* /gen */} {/* gen:platform_task_runs */} #### `platform_task_runs` [#platform_task_runs] Estado atual das runs sem tenant; o ator é `system` ou `scheduler`, e `scheduler` anda junto com `trigger = 'schedule'`. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `run_id` | `uuid` | não | | | `name` | `text` | não | | | `queue` | `text` | não | | | `trigger` | `text` | não | | | `actor_kind` | `text` | não | | | `actor_label` | `text` | não | | | `correlation_id` | `uuid` | não | | | `causation_id` | `uuid` | não | | | `status` | `text` | não | | | `attempts` | `integer` | não | padrão `0` | | `progress` | `smallint` | não | padrão `0` | | `outcome` | `jsonb` | não | padrão `'{}'::jsonb` | | `queued_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `started_at` | `timestamptz` | sim | | | `finished_at` | `timestamptz` | sim | | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma Referenciada por: `platform_task_run_events`, `platform_task_run_schedule_origins`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_task_runs_actor_kind_check` | `CHECK ((actor_kind IN ('system', 'scheduler')))` | | CHECK | `platform_task_runs_actor_label_check` | `CHECK (((length(actor_label) >= 1) AND (length(actor_label) <= 128)))` | | CHECK | `platform_task_runs_attempts_check` | `CHECK ((attempts >= 0))` | | CHECK | `platform_task_runs_check` | `CHECK (((actor_kind = 'scheduler') = (trigger = 'schedule')))` | | CHECK | `platform_task_runs_check1` | `CHECK (((status IN ('succeeded', 'failed', 'skipped')) = (finished_at IS NOT NULL)))` | | CHECK | `platform_task_runs_name_check` | `CHECK ((name ~ '^[a-z][a-z0-9._-]{1,63}$'))` | | CHECK | `platform_task_runs_outcome_check` | `CHECK (((jsonb_typeof(outcome) = 'object') AND (octet_length((outcome)::text) <= 2048)))` | | CHECK | `platform_task_runs_progress_check` | `CHECK (((progress >= 0) AND (progress <= 100)))` | | CHECK | `platform_task_runs_queue_check` | `CHECK ((queue IN ('critical', 'default', 'bulk', 'maintenance')))` | | CHECK | `platform_task_runs_status_check` | `CHECK ((status IN ('queued', 'running', 'retrying', 'succeeded', 'failed', 'skipped')))` | | CHECK | `platform_task_runs_trigger_check` | `CHECK ((trigger IN ('dispatch', 'event', 'schedule', 'redrive')))` | | PK | `platform_task_runs_pkey` | `PRIMARY KEY (run_id)` | | Constraint trigger | `platform_task_runs_origin_present` | adiada até o commit (`DEFERRABLE INITIALLY DEFERRED`) | | Trigger | `platform_task_runs_identity_fixed` | só colunas de ciclo de vida podem mudar | | Trigger | `platform_task_runs_never_deleted` | recusa `DELETE` (e `UPDATE`, nas tabelas de origem e ator) | | Trigger | `platform_task_runs_no_truncate` | recusa `TRUNCATE` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. {/* /gen */} {/* gen:platform_task_run_schedule_origins */} #### `platform_task_run_schedule_origins` [#platform_task_run_schedule_origins] Origem única de uma run agendada: nome do agendamento e horário previsto, únicos juntos. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `run_id` | `uuid` | não | | | `schedule_name` | `text` | não | | | `execution_id` | `text` | não | | | `scheduled_for` | `timestamptz` | não | | **Chaves estrangeiras** - `run_id` → `platform_task_runs.run_id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_task_run_schedule_origins_execution_id_check` | `CHECK (((length(execution_id) >= 1) AND (length(execution_id) <= 128)))` | | PK | `platform_task_run_schedule_origins_pkey` | `PRIMARY KEY (run_id)` | | UNIQUE | `platform_task_run_schedule_orig_schedule_name_scheduled_for_key` | `UNIQUE (schedule_name, scheduled_for)` | | Trigger | `platform_task_run_schedule_origins_never_deleted` | recusa `DELETE` (e `UPDATE`, nas tabelas de origem e ator) | | Trigger | `platform_task_run_schedule_origins_no_truncate` | recusa `TRUNCATE` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. {/* /gen */} {/* gen:platform_task_run_events */} #### `platform_task_run_events` [#platform_task_run_events] Trilha append-only das runs sem tenant. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `run_id` | `uuid` | não | | | `event` | `text` | não | | | `attempt` | `integer` | não | | | `receive_count` | `integer` | não | padrão `0` | | `queue` | `text` | não | | | `worker_instance` | `text` | não | | | `error_code` | `text` | não | padrão `''` | | `error_type` | `text` | não | padrão `''` | | `detail` | `jsonb` | não | padrão `'{}'::jsonb` | | `occurred_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `run_id` → `platform_task_runs.run_id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_task_run_events_attempt_check` | `CHECK ((attempt >= 0))` | | CHECK | `platform_task_run_events_detail_check` | `CHECK (((jsonb_typeof(detail) = 'object') AND (octet_length((detail)::text) <= 2048)))` | | CHECK | `platform_task_run_events_event_check` | `CHECK ((event IN ('queued', 'sent', 'received', 'started', 'progress', 'retry_scheduled', 'succeeded', 'failed', 'skipped', 'dead_lettered', 'released_on_shutdown', 'redriven')))` | | CHECK | `platform_task_run_events_receive_count_check` | `CHECK ((receive_count >= 0))` | | CHECK | `platform_task_run_events_worker_instance_check` | `CHECK (((length(worker_instance) >= 1) AND (length(worker_instance) <= 128)))` | | PK | `platform_task_run_events_pkey` | `PRIMARY KEY (id)` | | Trigger | `platform_task_run_events_append_only` | trilha append-only: recusa `UPDATE` e `DELETE` | | Trigger | `platform_task_run_events_no_truncate` | recusa `TRUNCATE` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. {/* /gen */} # Idempotência (/background/idempotencia) A fila entrega cada mensagem **pelo menos uma vez**. Para que o efeito aconteça uma vez só, todo handler roda pelo `GatedIdempotencyRunner`, com o `run_id` como chave (ou uma chave de negócio que a task declare). | Camada | Papel | | --- | --- | | Valkey (`SET NX PX`) | Portão: barra uma duplicata concorrente sem abrir transação e guarda o resultado para replay rápido | | PostgreSQL, na transação do efeito | Fonte da verdade: a chave e o resultado fazem commit ou somem junto com o efeito | | Depois do commit | O resultado é copiado para o Valkey | | Valkey sem a chave | Lê do PostgreSQL e repopula | | Valkey fora do ar | Cai para o PostgreSQL: mais lento, ainda correto | A gravação no PostgreSQL é síncrona, na mesma transação do efeito, e é dela que vem a garantia. Gravar a chave de forma assíncrona abriria dois buracos: o Valkey perder a chave antes da gravação (o efeito rodaria de novo) ou o efeito fazer rollback com a chave já marcada como concluída (o efeito se perderia). Uma reentrega que encontra a chave em processamento volta para a fila depois do lease; um resultado `completed` é devolvido como replay. ## Duas tabelas, por dono [#duas-tabelas-por-dono] Chaves de tenant ficam em `idempotency_keys`, com FK para `tenants`; chaves de trabalho acima dos tenants ficam em `platform_idempotency_keys`, sem coluna de tenant. Não há valor sentinela (como um tenant `0`): uma chave de tenant só existe para um tenant real, e o banco recusa o resto. A chave e a impressão digital da requisição são digests de 32 bytes. `outcome` é `{}` enquanto `processing` e o resultado (ou o detalhe da falha) depois; o banco garante as duas coisas por CHECK. {/* gen:idempotency_keys */} #### `idempotency_keys` [#idempotency_keys] Ledger de idempotência das operações de tenant: uma linha por (tenant, chave), reivindicada com `INSERT … ON CONFLICT DO NOTHING` na mesma transação do efeito. `expires_at` é o prazo do lease enquanto `processing` e o prazo de retenção depois. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `idempotency_key` | `bytea` | não | | | `request_fingerprint` | `bytea` | não | | | `status` | `text` | não | padrão `'processing'` | | `attempt` | `integer` | não | padrão `1` | | `outcome` | `jsonb` | não | padrão `'{}'::jsonb` | | `expires_at` | `timestamptz` | não | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `tenant_id` → `tenants.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `idempotency_keys_attempt_check` | `CHECK ((attempt > 0))` | | CHECK | `idempotency_keys_check` | `CHECK (((status <> 'processing') OR (outcome = '{}'::jsonb)))` | | CHECK | `idempotency_keys_idempotency_key_check` | `CHECK ((octet_length(idempotency_key) = 32))` | | CHECK | `idempotency_keys_outcome_check` | `CHECK (((jsonb_typeof(outcome) = 'object') AND (octet_length((outcome)::text) <= 4096)))` | | CHECK | `idempotency_keys_request_fingerprint_check` | `CHECK ((octet_length(request_fingerprint) = 32))` | | CHECK | `idempotency_keys_status_check` | `CHECK ((status IN ('processing', 'completed', 'failed_retryable', 'failed_permanent')))` | | PK | `idempotency_keys_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `idempotency_keys_owner_key` | `UNIQUE (tenant_id, idempotency_key)` | **Fora do banco** - A limpeza de chaves vencidas é feita pela task `maintenance.purge`. {/* /gen */} {/* gen:platform_idempotency_keys */} #### `platform_idempotency_keys` [#platform_idempotency_keys] O mesmo ledger para trabalho acima dos tenants, sem coluna de tenant: nenhuma chave de tenant usa valor sentinela. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `idempotency_key` | `bytea` | não | | | `request_fingerprint` | `bytea` | não | | | `status` | `text` | não | padrão `'processing'` | | `attempt` | `integer` | não | padrão `1` | | `outcome` | `jsonb` | não | padrão `'{}'::jsonb` | | `expires_at` | `timestamptz` | não | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_idempotency_keys_attempt_check` | `CHECK ((attempt > 0))` | | CHECK | `platform_idempotency_keys_check` | `CHECK (((status <> 'processing') OR (outcome = '{}'::jsonb)))` | | CHECK | `platform_idempotency_keys_idempotency_key_check` | `CHECK ((octet_length(idempotency_key) = 32))` | | CHECK | `platform_idempotency_keys_outcome_check` | `CHECK (((jsonb_typeof(outcome) = 'object') AND (octet_length((outcome)::text) <= 4096)))` | | CHECK | `platform_idempotency_keys_request_fingerprint_check` | `CHECK ((octet_length(request_fingerprint) = 32))` | | CHECK | `platform_idempotency_keys_status_check` | `CHECK ((status IN ('processing', 'completed', 'failed_retryable', 'failed_permanent')))` | | PK | `platform_idempotency_keys_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `platform_idempotency_keys_key` | `UNIQUE (idempotency_key)` | **Fora do banco** - A limpeza de chaves vencidas é feita pela task `maintenance.purge`. {/* /gen */} # Outbox, relay e filas (/background) O trabalho em segundo plano roda em `apps/workers`, um executável separado sobre `framework/jobs`. A API nunca roda tasks nem fala com a fila: ela grava o pedido no PostgreSQL, na mesma transação do caso de uso, e um relay o leva até o SQS depois do commit. ```mermaid sequenceDiagram participant UC as Caso de uso (API) participant PG as PostgreSQL participant R as Valkey participant Relay as Relay (workers) participant Q as SQS / ElasticMQ participant W as Worker UC->>PG: efeito + outbox_messages + task_runs (uma transação) UC-->>R: LPUSH de despertar (depois do commit) Relay->>PG: lê pendentes vencidos (FOR UPDATE SKIP LOCKED) Relay->>Q: SendMessageBatch Relay->>PG: marca sent_at W->>Q: ReceiveMessage (long polling) W->>PG: efeito + eventos da run (uma transação) W->>Q: DeleteMessage ``` ## Outbox: a verdade [#outbox-a-verdade] `work.dispatch(...)` e `work.publish(...)` gravam uma linha em `outbox_messages` (ou `platform_outbox_messages`, para trabalho sem tenant) e a linha da run em `task_runs` com status `queued`, **na transação do caso de uso**. Se o commit falha, não existe mensagem; se o commit passa, a mensagem existe mesmo que o processo morra no instante seguinte. - `run_id` (uuid) é a identidade de tudo: a linha do outbox, o envelope na fila e a run. - Atraso de qualquer duração vira `available_at`: o relay só envia o que já venceu, sem `DelaySeconds`. - Um evento com vários assinantes vira uma linha de outbox por assinante, cada uma com seu `run_id`; a falha de um assinante não afeta os outros. A API descobre os assinantes pelo catálogo `tasks.json`, sem importar o código deles. ## Valkey: só um acelerador [#valkey-só-um-acelerador] Depois do commit, a API faz `LPUSH` na chave `monalisa:outbox:wake`. O relay espera nela com `BLPOP` e acorda em milissegundos. Um sinal perdido não perde nada: o relay também varre o outbox a cada 5 s. ## Relay [#relay] O relay roda em exatamente um serviço de workers por ambiente (`MONALISA_RELAY=true`). Ele lê lotes com `WHERE sent_at IS NULL AND available_at <= clock_timestamp() … FOR UPDATE SKIP LOCKED`, envia com `SendMessageBatch` (até 10 mensagens e 256 KiB por chamada) e marca `sent_at` na mesma transação do lote. Falha ao enviar soma `attempts` e deixa a linha pendente. Um lote cheio dispara outra passada na hora, então um acúmulo drena na velocidade da fila. Como o consumidor é idempotente, reenviar depois de uma falha parcial é seguro. ## Filas [#filas] | Fila | Para quê | Visibilidade | | --- | --- | --- | | `critical` | o que alguém espera agora | 60 s | | `default` | trabalho comum | 120 s | | `bulk` | importações, relatórios | 15 min | | `maintenance` | limpeza e rotinas | 5 min | Cada fila tem uma dead-letter queue. Um processo consome as filas listadas em `QUEUES`. Enquanto uma mensagem está em processamento, um heartbeat estende a visibilidade. ## Falhas [#falhas] - **Permanente** (validação, proibido, não encontrado…): o runtime envia a mensagem para a dead-letter queue com o contexto do erro, registra o evento na trilha e a run vira `failed`. - **Transitória**: a mensagem volta para a fila com backoff exponencial (pela visibilidade, sem segurar o slot) e a run vira `retrying`. No 5º recebimento, segue o caminho da falha permanente. - **Processo morre no meio**: a mensagem reaparece pela visibilidade. A redrive nativa das filas manda para a dead-letter queue depois de 7 recebimentos (5 do runtime mais 2 de margem). - **Desligamento (SIGTERM)**: o worker para de buscar, sai de pronto, espera o que está em andamento até o orçamento de parada, e o que não terminou volta para a fila com a run em `queued`. ## Tabelas do outbox [#tabelas-do-outbox] {/* gen:outbox_messages */} #### `outbox_messages` [#outbox_messages] Outbox de tenant: cada dispatch ou evento publicado vira uma linha na transação do use case; `sent_at` nulo significa pendente. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `run_id` | `uuid` | não | | | `queue` | `text` | não | | | `envelope` | `jsonb` | não | | | `available_at` | `timestamptz` | não | | | `sent_at` | `timestamptz` | sim | | | `attempts` | `integer` | não | padrão `0` | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `tenant_id` → `tenants.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `outbox_messages_attempts_check` | `CHECK ((attempts >= 0))` | | CHECK | `outbox_messages_envelope_check` | `CHECK ((jsonb_typeof(envelope) = 'object'))` | | CHECK | `outbox_messages_queue_check` | `CHECK ((queue IN ('critical', 'default', 'bulk', 'maintenance')))` | | PK | `outbox_messages_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `outbox_messages_run_id_key` | `UNIQUE (run_id)` | **Fora do banco** - Linhas enviadas há mais de 7 dias são removidas pela task `maintenance.purge`; pendentes nunca. {/* /gen */} {/* gen:platform_outbox_messages */} #### `platform_outbox_messages` [#platform_outbox_messages] Outbox do trabalho sem tenant (plataforma, sistema, agendamentos), na mesma forma. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `run_id` | `uuid` | não | | | `queue` | `text` | não | | | `envelope` | `jsonb` | não | | | `available_at` | `timestamptz` | não | | | `sent_at` | `timestamptz` | sim | | | `attempts` | `integer` | não | padrão `0` | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_outbox_messages_attempts_check` | `CHECK ((attempts >= 0))` | | CHECK | `platform_outbox_messages_envelope_check` | `CHECK ((jsonb_typeof(envelope) = 'object'))` | | CHECK | `platform_outbox_messages_queue_check` | `CHECK ((queue IN ('critical', 'default', 'bulk', 'maintenance')))` | | PK | `platform_outbox_messages_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `platform_outbox_messages_run_id_key` | `UNIQUE (run_id)` | **Fora do banco** - Linhas enviadas há mais de 7 dias são removidas pela task `maintenance.purge`; pendentes nunca. {/* /gen */} # Tasks, eventos e agendamentos (/background/tasks-e-eventos) ## Três formas de trabalho, um runtime [#três-formas-de-trabalho-um-runtime] | Forma | Significa | Handlers | | --- | --- | --- | | `@Task` | comando: "faça isto" | exatamente um | | `@OnEvent` | fato: "isto aconteceu" | zero ou mais; cada assinante recebe sua própria entrega | | `@Schedule` | relógio: dispara uma task no horário | nenhum (só despacha a task) | ```ts @Schedule("rate(1 hour)") @Task({ name: "maintenance.purge", queue: "maintenance", context: "system", overlap: "skip", timeoutSeconds: 900, }) export class MaintenancePurge implements TaskHandler { parsePayload(): EmptyPayload { return {}; } async run(_payload: EmptyPayload, run: TaskRun): Promise { /* … */ } } @OnEvent({ type: "identity.session_started", version: 1, name: "identity.login_notification_email", queue: "critical", }) export class LoginNotificationEmail implements EventHandler { /* … */ } // Dentro de um caso de uso, na transação dele (GenerateReport e InvitationCreated são ilustrativos): await work.dispatch(GenerateReport, { reportId }, { delaySeconds: 30 }); await work.publish(new InvitationCreated({ invitationId })); ``` Opções de `@Task`: | Opção | Significa | | --- | --- | | `name` | Nome da unidade: segmentos separados por ponto de `[a-z][a-z0-9_]*`, até 64 caracteres | | `queue` | `critical`, `default`, `bulk` ou `maintenance` | | `concurrency` | Execuções simultâneas por processo | | `timeoutSeconds` | Prazo da execução | | `context` | `tenant` (padrão), `platform` ou `system` | | `overlap` | Só para agendadas: `skip` (padrão) pula uma execução sobreposta, `allow` deixa rodar | Despachar uma task cujo `context` não bate com quem chama é recusado (`RUN_CONTEXT_MISMATCH`): uma task `tenant` precisa de tenant, e uma `platform` ou `system` não pode ter. O handler recebe um `TaskRun`, nunca o envelope cru: `runId`, `name`, `attempt`, `scheduledFor`, `now()`, `logger`, `progress(percent)` e `transaction`. `transaction` é a transação do efeito, já com o tenant da run e `app.system_process = `: o que o handler grava por ela faz commit junto com os eventos `started` e `succeeded` da run. ## Unidades atuais [#unidades-atuais] | Unidade | Tipo | Fila | O que faz | | --- | --- | --- | --- | | `maintenance.purge` | `@Task` + `@Schedule("rate(1 hour)")` | `maintenance` | Remove em lotes chaves de idempotência vencidas e linhas de outbox enviadas há mais de 7 dias. Nunca toca nas runs nem na trilha. | | `identity.login_notification_email` | `@OnEvent` de `identity.session_started` v1 | `critical` | E-mail de "novo login". Continua registrado, mas nada publica o evento desde que o login antigo saiu. | ## O catálogo `tasks.json` [#o-catálogo-tasksjson] `tasks.json` lista cada task (nome, fila, `context`, agendamento) e cada assinatura. É gerado da descoberta das unidades (`*.task.ts` e `*.handler.ts`), nunca commitado: a imagem da API o recebe no build e o carrega de `MONALISA_TASKS_CATALOG`; sem ele a API não sobe. `bun run tasks:check` valida que a geração funciona (descoberta, nomes únicos, agendamentos válidos). ## Agendamentos [#agendamentos] Em desenvolvimento, `MONALISA_LOCAL_SCHEDULER=true` dispara os agendamentos dentro do processo; isso é recusado com `NODE_ENV=production`. Em staging a imagem roda em produção e não há agendador externo, então `maintenance.purge` não roda lá. Uma run agendada tem origem única por (agendamento, horário previsto), o que impede disparo duplicado. ## Configuração dos workers [#configuração-dos-workers] | Variável | Significa | | --- | --- | | `QUEUES` | Filas a consumir, separadas por vírgula (obrigatória) | | `MONALISA_RELAY` | `true` em exatamente um serviço por ambiente: ele move o outbox para a fila | | `MONALISA_LOCAL_SCHEDULER` | `true` para disparar agendamentos no processo; só desenvolvimento | | `MONALISA_SQS_REGION`, `MONALISA_SQS_QUEUE_URL_PREFIX`, `MONALISA_SQS_ENDPOINT` | Onde estão as filas (o nome da fila é anexado ao prefixo) | | `MONALISA_SQS_LOCAL_CREDENTIALS` | `true` para credenciais locais fixas em vez da cadeia da AWS | | `MONALISA_SQS_WAIT_SECONDS` | Long polling por recebimento, 1 a 20 (padrão 20) | | `MONALISA_STOP_TIMEOUT_MS` | Orçamento de parada; a drenagem recebe esse valor menos 5 s e precisa superar o long polling | | `MONALISA_HEALTH_PORT` | Porta do `/healthz` (padrão 8081) | | `DATABASE_POOL_SIZE` | Pelo menos `2 × slots + 2`, sendo slots a soma da concorrência das unidades das filas consumidas | A regra do pool é verificada no boot: cada slot pode segurar ao mesmo tempo a transação do efeito e uma de registro, mais uma conexão para o relay e uma para o agendador. Um worker com pool pequeno demais não sobe. ## Ordem de deploy [#ordem-de-deploy] Quando uma versão adiciona uma task ou um assinante, publique os workers antes da API. Uma mensagem para uma unidade que os workers em execução ainda não conhecem volta para a fila com backoff (`UNIT_NOT_REGISTERED`) e só vai para a dead-letter queue depois de cinco recebimentos; os workers novos normalmente a pegam antes, mas a ordem elimina a corrida. # Auditoria das execuções (/garantias/auditoria-de-execucoes) Cada execução em segundo plano (uma task, um assinante de evento ou um agendamento) tem uma linha em `task_runs` (de tenant) ou `platform_task_runs` (sem tenant) e uma trilha de eventos em `task_run_events` ou `platform_task_run_events`. Essas tabelas são auditoria permanente: nenhuma rotina de limpeza as toca, e o banco recusa reescrevê-las. ## O que os triggers impõem [#o-que-os-triggers-impõem] | Regra | Triggers | | --- | --- | | Eventos são append-only: `UPDATE` e `DELETE` recusados | `task_run_events_append_only`, `platform_task_run_events_append_only` | | Runs nunca são apagadas | `task_runs_never_deleted`, `platform_task_runs_never_deleted` | | Ator e origem não mudam nem somem (`UPDATE` e `DELETE` recusados) | `task_run_user_actors_never_deleted`, `platform_task_run_schedule_origins_never_deleted` | | `TRUNCATE` recusado nas seis tabelas (triggers de linha não disparam em `TRUNCATE`) | `*_no_truncate`, em nível de statement | | Identidade da run congelada: só `status`, `attempts`, `progress`, `outcome`, `started_at`, `finished_at` e `updated_at` mudam | `task_runs_identity_fixed`, `platform_task_runs_identity_fixed` | Todos usam a mesma função de recusa, cuja mensagem diz o que era esperado: `audit trail is append-only — expected INSERT only on `. ## Ator explícito [#ator-explícito] Toda run diz quem a causou, e o banco confere: - `task_runs.actor_kind` é `user` ou `system`. Uma run de `user` precisa de uma linha em `task_run_user_actors` (usuário e membership do mesmo tenant) na mesma transação: a constraint trigger `task_runs_user_actor_present` é `DEFERRABLE INITIALLY DEFERRED` e verifica no commit. - `platform_task_runs.actor_kind` é `system` ou `scheduler`; `scheduler` anda junto com `trigger = 'schedule'` (CHECK). Uma run de `scheduler` precisa de uma origem em `platform_task_run_schedule_origins`, verificada no commit por `platform_task_runs_origin_present`. A origem é única por (agendamento, horário previsto), então o mesmo disparo não gera duas runs. - Runs de operador de plataforma ainda não existem: o CHECK de `actor_kind` não aceita esse valor, e o dispatcher recusa em vez de gravar como sistema. ## Outras garantias por linha [#outras-garantias-por-linha] - `status` em `queued`, `running`, `retrying`, `succeeded`, `failed`, `skipped`, e `finished_at` preenchido se e somente se o status é final (CHECK). - `name` no formato `^[a-z][a-z0-9._-]{1,63}$`, `progress` entre 0 e 100, `outcome` e `detail` são objetos JSON de até 2048 bytes. - `task_runs` tem FK para `tenants`; os eventos apontam para a run pelo par `(run_id, tenant_id)`, então ficam no mesmo tenant da run. As colunas e constraints completas estão em [Execuções e auditoria](/background/execucoes). ## O que fica fora do banco [#o-que-fica-fora-do-banco] - Retirar `UPDATE` e `DELETE` dessas tabelas por permissão de papel (`REVOKE`) ainda não foi feito; vem com os papéis de banco da política de RLS. Hoje a proteção são os triggers. - O redrive de mensagens da dead-letter queue (`trigger = 'redrive'`) está previsto no schema, mas a ferramenta de operação ainda não existe. # Chaves identity (/garantias/chaves-identity) ## `GENERATED ALWAYS AS IDENTITY` [#generated-always-as-identity] Toda tabela com PK `id` numérica usa `bigint GENERATED ALWAYS AS IDENTITY`. É o caso de 64 das 82 tabelas do banco migrado (todas as que têm `id`, menos a de controle de migrations). `ALWAYS` significa que um `INSERT` que informa `id` é recusado: o valor sempre vem do banco, então não há colisão com sequências nem ids escolhidos pelo cliente. ```sql create table "parties" ( "id" bigint generated always as identity not null primary key, ... ); ``` As entities declaram a PK assim e a migration gerada traz a cláusula; ninguém escreve essa DDL à mão. ## Quando a PK não é um `id` [#quando-a-pk-não-é-um-id] | Forma | Tabelas | Por quê | | --- | --- | --- | | PK é a FK do pai (1:1) | `person_profiles`, `company_profiles` (`party_id`), `financial_institution_credentials` (`credential_id`), `invitation_session_contexts` (`invitation_id`), `session_membership_brokers` (`session_membership_id`), `bank_credential_deliveries` (`credential_version_id`) | A linha estende outra e existe no máximo uma por pai. Em `bank_credential_deliveries`, isso é a regra "uma revelação por versão da senha". | | PK composta | `access_profile_permissions`, `access_profile_template_permissions`, `platform_access_profile_permissions`, `platform_access_profile_template_permissions`, `permission_use_cases`, `user_credentials`, `access_profile_template_change_decisions` | Tabelas de ligação: o par já é a identidade. | | PK `uuid` | `task_runs`, `platform_task_runs`, `task_run_user_actors`, `platform_task_run_schedule_origins` (`run_id`) | O `run_id` nasce na aplicação no momento do dispatch e é o mesmo no outbox, no envelope da fila e na execução. | ## Identificadores públicos [#identificadores-públicos] Os `id` numéricos são internos. O que sai para fora (URLs, payloads, links) é a coluna `uid`, `text` e única, nas tabelas que têm identidade pública: `tenants`, `credentials`, `sessions`, `brokers`, `broker_affiliations`, `teams`, `memberships`, `invitations`, `invitation_deliveries`, `platform_invitations`, `platform_invitation_deliveries`, `broker_prospects` e `portfolio_transfers` (este, único por tenant). ## UNIQUE de apoio [#unique-de-apoio] Muitas tabelas declaram um UNIQUE que parece redundante com a PK, como `(id, tenant_id)` ou `(id, user_bank_id, tenant_id, broker_id)`. Eles existem para servir de alvo das FKs compostas: o PostgreSQL só aceita uma FK para um conjunto de colunas que seja PK ou UNIQUE. São eles que permitem garantir "mesmo tenant" e "mesmo dono" nas relações (ver [Garantias do banco](/garantias)). # Garantias do banco (/garantias) O princípio é simples: o que pode ser uma constraint vira constraint. As entities declaram PK, UNIQUE, CHECK, enums em texto, índices únicos parciais e colunas geradas; o SQL escrito à mão guarda só o que o ORM não expressa (EXCLUDE, FKs compostas que reusam colunas, FKs para domínios posteriores e triggers). Regras que dependem de várias linhas ou de contexto da requisição ficam na aplicação, e cada página de domínio diz quais são. ## Em números [#em-números] Contagem do banco migrado (82 tabelas, incluindo as de infraestrutura): | Garantia | Quantidade | | --- | --- | | Chaves estrangeiras | 175 (166 nas tabelas de domínio, 69 delas compostas) | | CHECK | 92 | | UNIQUE (constraints) | 87 | | Índices únicos | 17 (16 parciais) | | EXCLUDE | 4 | | Triggers | 22, mais 2 constraint triggers adiadas | | Colunas geradas | 2 | Nenhuma FK é `DEFERRABLE` e nenhuma tem ação em cascata. A extensão `btree_gist` é habilitada para os EXCLUDE. ## Mesmo tenant por construção [#mesmo-tenant-por-construção] Quando uma tabela de tenant aponta para outra, a FK inclui `tenant_id` dos dois lados. A tabela de destino declara um UNIQUE "de apoio" com `(id, tenant_id)` (ou mais colunas) só para servir de alvo: ```sql -- membership_teams só aceita um time e uma membership do próprio tenant FOREIGN KEY (team_id, tenant_id) REFERENCES teams (id, tenant_id) FOREIGN KEY (membership_id, tenant_id) REFERENCES memberships (id, tenant_id) ``` O mesmo padrão prende outras colunas copiadas. `membership_brokers.user_id` é uma cópia de `memberships.user_id`, presa por `FOREIGN KEY (membership_id, user_id, tenant_id) REFERENCES memberships (id, user_id, tenant_id)`; o perfil de um vínculo com corretor precisa ser daquele corretor (`(access_profile_id, tenant_id, broker_id)`); e o perfil de um vínculo de tenant precisa ter `scope = 'tenant'`, via a coluna gerada `access_profiles.scope` e um CHECK no vínculo. ## Uma ativa por vez [#uma-ativa-por-vez] As unicidades condicionais são índices únicos parciais: valem só para as linhas abertas. Exemplos: | Índice | Garante | | --- | --- | | `memberships_live_key` | uma membership ativa por (tenant, usuário); quem foi removido pode voltar | | `membership_brokers_live_key` | um vínculo aberto por (membership, corretor) | | `platform_grants_live_key` | um grant de plataforma ativo por usuário | | `credential_versions_one_active_key` | uma versão ativa por credencial | | `bank_assignments_one_open_key` | uma atribuição aberta por usuário bancário | | `user_logins_live_key` | um identificador ativo por (tenant, tipo, valor normalizado) | O índice único parcial impede duas linhas abertas, mas não impede períodos fechados sobrepostos. Onde isso importa, o banco usa EXCLUDE: ver [Integridade temporal](/garantias/integridade-temporal). ## Páginas [#páginas] - [Integridade temporal](/garantias/integridade-temporal): vigências `[início, fim)` e os 4 EXCLUDE. - [Teto de escopo das permissões](/garantias/teto-de-escopo): triggers, locks e a convenção de SQL congelado. - [Chaves identity](/garantias/chaves-identity): PKs `GENERATED ALWAYS AS IDENTITY`. - [Auditoria das execuções](/garantias/auditoria-de-execucoes): a trilha permanente de tasks e eventos. # Integridade temporal (/garantias/integridade-temporal) ## Instantes e dias [#instantes-e-dias] - Todo instante é `timestamptz`, armazenado em UTC. A convenção é serializar em ISO 8601 com `Z`; conversão de fuso é só apresentação. - Os horários de criação, consumo e auditoria são gerados pelo PostgreSQL, nunca aceitos do cliente. Os padrões das colunas usam `clock_timestamp()` (101 colunas no banco migrado), não `now()`: `now()` é o início da transação e pode ser anterior a uma espera por lock, então não serve como corte depois dessa espera. - Vigência de negócio é `date`, com intervalo semiaberto `[valid_from, valid_until)`: o dia em `valid_until` já pertence ao período seguinte. `valid_until` nulo significa sem fim definido. ## Os 4 EXCLUDE [#os-4-exclude] Um EXCLUDE com `daterange(valid_from, valid_until, '[)')` e o operador `&&` recusa duas linhas cujos períodos se sobrepõem para a mesma chave. Como as chaves são `bigint`, os EXCLUDE usam GiST com a extensão `btree_gist`. | Constraint | Tabela | Sem sobreposição para | | --- | --- | --- | | `broker_store_codes_no_overlap` | `broker_store_codes` | (tenant, corretor, IF): um código por corretor por IF de cada vez | | `membership_team_no_overlap` | `membership_teams` | (time, membership) | | `team_fi_service_no_overlap` | `team_financial_institution_accounts` | (time, vínculo CNPJ × IF) | | `team_hierarchy_edge_no_overlap` | `team_hierarchies` | (subordinado, superior): a mesma aresta | ```sql ALTER TABLE broker_store_codes ADD CONSTRAINT broker_store_codes_no_overlap EXCLUDE USING gist (tenant_id WITH =, broker_id WITH =, financial_institution_id WITH =, daterange(valid_from, valid_until, '[)') WITH &&); ``` Essas quatro tabelas também têm um CHECK de período (`valid_until IS NULL OR valid_until > valid_from`). ## Tabelas com vigência [#tabelas-com-vigência] | Tabela | CHECK de período | EXCLUDE | Uma aberta por vez (índice parcial) | | --- | --- | --- | --- | | `broker_store_codes` | sim | sim | não | | `membership_teams` | sim | sim | sim | | `team_financial_institution_accounts` | sim | sim | não | | `team_hierarchies` | sim | sim | não | | `membership_portfolios` | sim | não | sim (por tenant e corretor) | | `bank_user_assignments` | sim | não | sim (por usuário bancário) | | `membership_access_profiles` | não | não | sim | | `membership_brokers` | não | não | sim | | `tenant_broker_accounts` | não | não | sim | | `broker_affiliations` (`started_on`/`ended_on`) | não | não | sim | | `company_ownerships` (`started_on`/`ended_on`) | não | não | não | Onde a tabela não tem CHECK nem EXCLUDE, a ordem das datas e a ausência de sobreposição entre períodos já fechados ficam por conta da aplicação. ## O que o banco não cobre [#o-que-o-banco-não-cobre] - **Cobertura entre tabelas.** Uma carteira (`membership_portfolios`) e uma aresta de hierarquia (`team_hierarchies`) deveriam caber na vigência das `membership_teams` de que dependem. Um CHECK olha uma linha só, então essa cobertura não é garantida pelo banco. - **Ciclos na hierarquia.** O EXCLUDE de `team_hierarchies` impede a mesma aresta sobreposta no tempo, não ciclos nem inserções concorrentes que formem um. Isso exige uma constraint trigger ou um protocolo transacional, ainda não implementados. - **Transferências.** Fechar a origem e abrir o destino na mesma data (`portfolio_transfers`, `bank_user_transfers`) numa única transação é regra da aplicação; nenhuma constraint liga as duas linhas. # Teto de escopo das permissões (/garantias/teto-de-escopo) Toda permissão tem um escopo (`platform`, `tenant` ou `broker`), e todo titular de permissões também. O banco garante que um titular nunca recebe permissão acima do seu escopo: | Escopo do titular | Aceita permissões de escopo | | --- | --- | | `platform` | `platform` | | `tenant` | `tenant`, `broker` | | `broker` | `broker` | Os titulares e suas tabelas de ligação: | Titular | Escopo vem de | Tabela de ligação | | --- | --- | --- | | `access_profiles` | coluna gerada `scope` (`tenant` sem `broker_id`, `broker` com) | `access_profile_permissions` | | `access_profile_templates` | coluna `scope` | `access_profile_template_permissions` | | `platform_access_profiles` | sempre `platform` | `platform_access_profile_permissions` | | `platform_access_profile_templates` | coluna `scope` (`tenant` ou `broker`: o template é copiado para um tenant) | `platform_access_profile_template_permissions` | A matriz tem uma única fonte no banco, a função `permission_scope_ceiling(holder_scope)`. O código de domínio tem um espelho dela para devolver o erro antes de chegar ao banco, e um teste prende os dois juntos. ## Três direções [#três-direções] | Mudança | Trigger | Quando | | --- | --- | --- | | Inserir ou alterar uma ligação | `*_permissions_scope_ceiling` nas 4 tabelas de ligação | `BEFORE INSERT OR UPDATE` | | Mudar o escopo de um titular | `access_profiles_scope_ceiling`, `access_profile_templates_scope_ceiling`, `platform_access_profile_templates_scope_ceiling` | `AFTER UPDATE`, só quando `scope` muda | | Mudar o escopo de uma permissão | `permissions_scope_ceiling` | `AFTER UPDATE OF scope` | O trigger de `access_profiles` é `AFTER UPDATE` (e não `UPDATE OF scope`) porque `scope` é uma coluna gerada `STORED`: ela só é calculada depois dos triggers `BEFORE` e não pode ser nomeada em `UPDATE OF`. A recusa tem uma forma só, que quem chama pode verificar: SQLSTATE `23514` (`check_violation`), constraint `permission_scope_ceiling` e uma mensagem com o código da permissão, o escopo dela e o escopo esperado. Uma permissão ou um titular inexistente não é erro do teto: fica para a FK reportar. ## Concorrência: os locks [#concorrência-os-locks] As primeiras versões das checagens liam o titular e a permissão sem lock. Em `READ COMMITTED`, duas transações podiam passar cada uma sozinha e ambas fazerem commit: uma ligação inserida enquanto o escopo do titular ou da permissão mudava (NOVA-112). A migration `0017_authorization-scope-ceiling-locks` redefine as duas leituras com `SELECT … FOR SHARE`, que conflita com qualquer `UPDATE` dessas linhas: - uma mudança de escopo que chega em segundo lugar espera a transação que segura o lock terminar; o guard `AFTER` dela roda com um snapshot novo, vê a ligação já commitada e recusa; - uma leitura com lock que chega em segundo lugar espera o commit da mudança de escopo, relê o escopo novo e recusa. A ordem dos locks é sempre a mesma: primeiro a linha da permissão, depois a do titular. Uma transação que altera um titular e depois liga uma permissão que outra transação está reescopando ainda pode entrar em deadlock; o PostgreSQL aborta uma delas, o que recusa a operação e nunca a deixa passar. ## A convenção de SQL congelado [#a-convenção-de-sql-congelado] O teto foi construído em três arquivos, cada um carregado por uma migration própria, porque SQL mergeado não se edita: | Migration | Arquivo | O que faz | | --- | --- | --- | | `0012_authorization-scope-ceiling` | `scope-ceiling.sql` | Matriz, recusa e os triggers das ligações e dos titulares | | `0013_authorization-permission-scope-guard` | `permission-scope-guard.sql` | O lado da permissão: o seeder atualiza `permissions.scope` a cada deploy | | `0017_authorization-scope-ceiling-locks` | `scope-ceiling-locks.sql` | Redefine as leituras com `FOR SHARE` (mesmas assinaturas, os triggers seguem chamando) | A correção de concorrência não editou `scope-ceiling.sql`: ela é um arquivo novo que faz `CREATE OR REPLACE` das funções. Os três arquivos estão fixados por sha256 (ver [SQL congelado](/migrations/sql-congelado)). ## O que fica fora do banco [#o-que-fica-fora-do-banco] O teto compara escopos. "Quem concede só concede o que tem", "ninguém concede a si mesmo" e a posição de quem concede são regras das rotas de concessão (NOVA-43, NOVA-59), não do banco. # O que é o Monalisa (/) O Monalisa é o backend do Rocket. É um monorepo em TypeScript sobre Bun com três executáveis (a API, os workers e o migrator), pacotes de domínio e um framework interno para injeção de dependências, configuração, HTTP, autorização, acesso ao PostgreSQL, idempotência e trabalho em segundo plano. O projeto está em pré-lançamento: código, contratos e o baseline do banco formam uma única implementação atual. A API pública responde em `api.novahml.com`. ## Em números [#em-números] | | | | --- | --- | | Domínios do modelo de dados | 11 | | Tabelas de domínio | 71 | | Tabelas de infraestrutura (idempotência e background work) | 10 | | Migrations no baseline | 17 (`0001_parties` a `0017_authorization-scope-ceiling-locks`) | | Banco | PostgreSQL 18 | O banco migrado tem 82 tabelas: as 71 de domínio, as 10 de infraestrutura e a tabela de controle de migrations. ## Como navegar [#como-navegar] - [Arquitetura](/arquitetura): os executáveis e os serviços de que dependem. - [Rodar localmente](/rodar-localmente): do clone ao `make dev`. - [Modelo de dados](/modelo-de-dados): uma página por domínio, com diagrama, colunas, chaves e constraints. - [Garantias do banco](/garantias): o que o PostgreSQL impõe por conta própria. - [Migrations e seeds](/migrations): como o schema nasce das entities e chega a um ambiente. - [Background work](/background): outbox, filas, tasks, eventos e a auditoria das execuções. - [Operação](/operacao/staging-dokploy): staging no Dokploy, backups e política de versões. - [Autenticação](/autenticacao): em reconstrução. ## Convenções desta documentação [#convenções-desta-documentação] - **Modelo de dados** é a descrição das 71 tabelas, agrupadas em 11 domínios. As entities de cada pacote de domínio geram o schema a partir dele, e um teste compara o banco migrado com o modelo. - **Garantido pelo banco** lista só o que existe no schema migrado: PK, UNIQUE, índices únicos parciais, CHECK, EXCLUDE, triggers e colunas geradas. Cada página de domínio separa isso do que fica **fora do banco**. - Todo instante é `timestamptz` em UTC; vigências de negócio são `date` com intervalo `[valid_from, valid_until)`: o dia de `valid_until` já não pertence ao período. - Identificadores públicos são as colunas `uid`; os `id` numéricos são internos. Esta documentação também está disponível para LLMs em [`/llms.txt`](/llms.txt) (índice) e [`/llms-full.txt`](/llms-full.txt) (conteúdo completo). # Deploy no migrator (/migrations/deploy) A imagem `migrator` é o passo de deploy do banco. O comando padrão dela é: ```sh bun src/main.ts deploy ``` `deploy` aplica as migrations pendentes do baseline sob um advisory lock e depois roda os [seeders padrão](/migrations/seeders) numa transação. Rodar `deploy` de novo num banco em dia não muda nada. | Comando | O que faz | | --- | --- | | `bun src/main.ts deploy` | Migrations e seeders padrão (o padrão da imagem) | | `bun src/main.ts apply` | Só as migrations | | `bun src/main.ts status` | Lista o estado das migrations | | `bun src/db-seed.ts` | Só os seeders | ## Onde ele roda [#onde-ele-roda] - **Compose (`compose.yaml`)**: o serviço `migrator` roda a cada `up`; a API e os workers só sobem depois que ele termina com sucesso. - **Fora do Compose**: rode a imagem uma vez por versão, antes da API e dos workers. - **Staging (Dokploy)**: o migrator é uma aplicação one-shot. Ver [Staging no Dokploy](/operacao/staging-dokploy). ## O portão de prontidão [#o-portão-de-prontidão] A API e os workers não confiam na ordem de deploy. As imagens carregam o nome da migration mais nova do próprio build (`/app/contracts/schema-version.json`, apontado por `MONALISA_SCHEMA_VERSION`): - a API responde `GET /ready` com 503 e a migration pendente até o banco conter essa migration; - os workers não ficam prontos nem consomem até lá. As duas respostas de `/ready` trazem `latestMigration`, que o deploy de staging usa para confirmar que a versão nova está no ar. Assim uma API ou um worker novo nunca serve sobre um schema antigo, mesmo quando sobe antes do migrator. ## Artefatos gerados no build [#artefatos-gerados-no-build] `openapi.json`, `permissions.json`, `tasks.json` e `schema-version.json` são gerados a partir do código e nunca commitados. As imagens os recebem num estágio do build: um emit que falha (regra de rota quebrada, registry de permissões inválido, unidade de trabalho não descoberta) falha o build da imagem. O migrator recebe o `permissions.json` emitido pelo mesmo build. # Schema gerado pelas entities (/migrations) As entities dos pacotes de domínio são a fonte do schema. Ninguém escreve à mão DDL de tabela, coluna, FK simples ou índice: as entities declaram, e `migrations:create` gera a migration pelo diff. ## O baseline [#o-baseline] O baseline atual tem 17 migrations: primeiro os domínios, depois a infraestrutura. | Migration | Origem | | --- | --- | | `0001_parties` … `0011_bank-users` | Geradas: a primeira migration de cada um dos 11 domínios, que termina carregando o `constraints.sql` do domínio | | `0012_authorization-scope-ceiling` | Escrita à mão: só carrega `scope-ceiling.sql` | | `0013_authorization-permission-scope-guard` | Escrita à mão: só carrega `permission-scope-guard.sql` | | `0014_identity-platform-logins` | Gerada: a tabela `platform_logins` | | `0015_idempotency` | Escrita à mão: carrega `0015_idempotency.sql` (o ledger de idempotência não tem entities) | | `0016_work` | Escrita à mão: carrega `0016_work.sql` (outbox, runs e trilha) | | `0017_authorization-scope-ceiling-locks` | Escrita à mão: só carrega `scope-ceiling-locks.sql` | O migrator registra cada migration aplicada na tabela `mikro_orm_migrations`, e aplica as pendentes sob um advisory lock, então duas execuções simultâneas não se atropelam. ## Mudar o schema [#mudar-o-schema] 1. Altere a entity. 2. Gere a migration offline e revise o arquivo: ```sh bun run migrations:create ``` 3. `bun run migrations:check` (também no CI) falha enquanto entities e migrations divergem, e também se algum SQL congelado foi editado (ver [SQL congelado](/migrations/sql-congelado)). Uma migration mergeada nunca é editada: qualquer correção é uma migration nova. ### Nomes [#nomes] - O nome puro do domínio (`parties`) é só da **primeira** migration do domínio, a que carrega o `constraints.sql` dele. - Toda migration seguinte do domínio usa `-`, como `identity-platform-logins`, e não carrega arquivo de constraints. - O gerador numera o arquivo (`0018_…`) e recusa um nome já usado. ### Diff por snapshot, não contra o banco [#diff-por-snapshot-não-contra-o-banco] O diff compara as entities com um snapshot (`.snapshot-entities.json`), não com um banco vivo. Comparar com o banco derrubaria objetos criados por SQL à mão, como EXCLUDE e triggers, que o ORM não conhece. ### O que vai na entity e o que vai em SQL [#o-que-vai-na-entity-e-o-que-vai-em-sql] | Na entity | Em SQL escrito à mão | | --- | --- | | PK `GENERATED ALWAYS AS IDENTITY`, colunas, FKs simples | EXCLUDE com GiST | | UNIQUE, incluindo os UNIQUE de apoio | FK composta que reusa colunas de outra FK | | Índice único parcial (`where`) e `NULLS NOT DISTINCT` | FK para um domínio posterior (criada pelo domínio posterior) | | CHECK, enums em texto, colunas geradas | Funções e triggers | ## Rede contra deriva [#rede-contra-deriva] Além do `migrations:check`, um teste de paridade compara o banco migrado com o modelo de dados: colunas, padrões, FKs (ação e `DEFERRABLE`) e índices únicos. Se o modelo e as entities divergirem, o teste falha. # Seeders e bootstrap da plataforma (/migrations/seeders) Os seeders padrão rodam em ordem, **numa única transação**, depois de conferir que o baseline está aplicado: | Seeder | O que faz | Repetir | | --- | --- | --- | | `CatalogSeeder` | Catálogo de produtos de referência: IFs, grupos de produto, produtos, tipos de operação e ofertas, a partir de um JSON validado | Faz merge pela chave natural: rodar de novo só atualiza | | `PermissionsSeeder` | Projeta o registry de permissões (`permissions.json`, gerado dos `@Requires`) em `permissions` e `permission_use_cases` | Upsert por `code`, atualizando `description` e `scope` | | `PlatformBootstrapSeeder` | Cria o primeiro operador de plataforma | One-shot: não faz nada se já existe qualquer grant de plataforma | ```sh bun run db:seed # os três seeders padrão bun run db:seed --class DevSeeder # dados locais; recusado com NODE_ENV=production ``` Na imagem do migrator, o registry de permissões vem do mesmo build (`MONALISA_PERMISSIONS_REGISTRY`); fora dela, rode `bun run permissions:emit` antes. Como o seeder de permissões atualiza `permissions.scope` a cada deploy, a mudança de escopo de uma permissão passa pelo [teto de escopo](/garantias/teto-de-escopo) e é recusada se deixar algum perfil acima do teto. Marcar permissões removidas do código (`deprecated_at`), tratar renomeações (`replaces_code`) e registrar `permission_changes` ainda não são feitos pelo seeder; ficam para a sincronização de permissões no deploy (NOVA-37). ## O bootstrap da plataforma [#o-bootstrap-da-plataforma] O `PlatformBootstrapSeeder` cria, na mesma transação: 1. a party e o usuário do operador; 2. um login de plataforma do tipo username, sem tenant (`platform_logins`); 3. uma credencial de senha, com o hash calculado a partir da variável de ambiente; 4. o perfil de administração da plataforma, com as permissões de escopo `platform`; 5. um convite de plataforma já aceito, que é a origem obrigatória do grant; 6. o grant de plataforma (concedido por ele mesmo, porque não existe operador antes dele). O bootstrap não cria tenant. **É one-shot.** Se existe qualquer linha em `platform_grants`, ativa ou revogada, ele não faz nada e nem lê as variáveis. Revogar o operador, trocar o username ou a senha nas variáveis, ou editar o perfil nunca recria nem ressuscita nada; operadores seguintes vêm de convites. Um username que já foi login de plataforma é recusado em vez de ser tomado. Nada secreto é registrado em log. | Variável | Significa | | --- | --- | | `PLATFORM_BOOTSTRAP_USERNAME` | Login de plataforma do primeiro operador: 1 a 64 caracteres `[a-zA-Z0-9_.-]` | | `PLATFORM_BOOTSTRAP_PASSWORD` | A senha, 1 a 1024 bytes UTF-8, de um segredo de deploy; nunca registrada em log | | `PLATFORM_BOOTSTRAP_EMAIL` | E-mail do convite de plataforma aceito de onde o grant nasce | | `PLATFORM_BOOTSTRAP_NAME` | Nome de exibição; o padrão é o username | As três primeiras só são exigidas no primeiro deploy, num banco sem operador de plataforma: ele falha, nomeando a variável que falta, até que sejam definidas. Depois disso o bootstrap não as lê mais, então as versões seguintes podem rodar sem o segredo da senha. Enquanto a autenticação é reconstruída, o operador existe no banco, mas não há rota de login para ele (ver [Autenticação](/autenticacao)). ## `DevSeeder` [#devseeder] Só para desenvolvimento: cria um tenant de exemplo, uma conta, um corretor, perfis e alguns membros com CPFs sintaticamente válidos que não pertencem a ninguém. É opcional (`--class DevSeeder`) e recusado com `NODE_ENV=production`. Os seeders que procuram uma linha antes de inseri-la não são seguros contra dois `db:seed` simultâneos; isso é aceitável enquanto o deploy roda um migrator por vez. # SQL congelado (/migrations/sql-congelado) Algumas migrations não trazem o SQL inline: elas leem um arquivo `.sql` no momento em que são aplicadas. Se esse arquivo fosse editado depois do merge, um banco novo receberia a versão nova e todo banco que já rodou a migration ficaria com a antiga, sem aviso. Por isso a regra: > SQL escrito à mão que uma migration carrega fica **congelado** depois do merge. Uma mudança posterior vai num arquivo > novo, carregado por uma migration nova. Todo arquivo congelado começa com a linha: ```sql -- FROZEN once merged: later changes go in a new file loaded by a new migration. ``` ## Os arquivos fixados [#os-arquivos-fixados] `apps/migrator/migrations/frozen-sql.json` fixa o sha256 de cada arquivo carregado por uma migration. Hoje são 16: | Arquivo | Carregado por | | --- | --- | | `packages//src/schema/constraints.sql` (11 arquivos, um por domínio) | `0001` a `0011`, a primeira migration de cada domínio | | `packages/authorization/src/schema/scope-ceiling.sql` | `0012_authorization-scope-ceiling` | | `packages/authorization/src/schema/permission-scope-guard.sql` | `0013_authorization-permission-scope-guard` | | `apps/migrator/migrations/0015_idempotency.sql` | `0015_idempotency` | | `apps/migrator/migrations/0016_work.sql` | `0016_work` | | `packages/authorization/src/schema/scope-ceiling-locks.sql` | `0017_authorization-scope-ceiling-locks` | Alguns `constraints.sql` são intencionalmente vazios (só comentários), quando tudo do domínio é expresso pelas entities; eles continuam fixados. ## O que o `migrations:check` recusa [#o-que-o-migrationscheck-recusa] - um arquivo carregado cujo sha256 difere do fixado (**editado**); - um arquivo carregado sem entrada no `frozen-sql.json` (**não fixado**); - uma entrada que não corresponde a nenhum arquivo carregado (**obsoleta**). Fixe o arquivo novo no mesmo commit que adiciona a migration que o carrega. ## Exemplo: a correção de concorrência do teto de escopo [#exemplo-a-correção-de-concorrência-do-teto-de-escopo] A checagem do teto de escopo, criada em `0012`, lia as linhas sem lock e podia ser furada por duas transações concorrentes (NOVA-112). A correção não tocou em `scope-ceiling.sql`: ela é o arquivo novo `scope-ceiling-locks.sql`, carregado pela migration nova `0017_authorization-scope-ceiling-locks`, que faz `CREATE OR REPLACE` das funções com as mesmas assinaturas. Detalhes em [Teto de escopo](/garantias/teto-de-escopo). # Authorization (/modelo-de-dados/authorization) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} A permissão nasce no código, junto do use case que ela protege (`@Requires`); a tabela `permissions` é uma projeção desse registry, sincronizada pelo seeder a cada deploy, e ninguém a edita em tela. Um **perfil de acesso** é um conjunto de permissões definido pelo tenant: sem `broker_id` é do tenant, com `broker_id` é de um corretor (`scope` é uma coluna gerada a partir disso). Templates de tenant e de plataforma descrevem formatos de perfil que são copiados ou seguidos. Acima dos tenants ficam os perfis e grants de plataforma. O banco impõe o **teto de escopo**: um perfil ou template nunca recebe permissão acima do seu escopo (ver [Teto de escopo](/garantias/teto-de-escopo)). ```mermaid erDiagram permissions ||--o{ access_profile_permissions : "concedida" access_profiles ||--o{ access_profile_permissions : "lista" access_profile_templates ||--o{ access_profiles : "seguido por" platform_tenant_blueprints ||--o{ platform_access_profile_templates : "templates" platform_access_profiles ||--o{ platform_grants : "concede" platform_invitations ||--o{ platform_grants : "origem" platform_grants ||--o{ session_contexts : "atua no tenant" ``` ## Tabelas (19) [#tabelas-19] | Tabela | Para quê | | --- | --- | | [`permissions`](#permissions) | Projeção do registry de permissões gerado pelos `@Requires` dos use cases: código `..`, escopo e descrição. | | [`permission_use_cases`](#permission_use_cases) | Quais use cases exigem cada permissão; sincronizado no deploy. | | [`permission_changes`](#permission_changes) | Auditoria prevista de mudanças no catálogo de permissões (commit, autor, aprovador, quem fez o deploy). | | [`access_profiles`](#access_profiles) | Perfil de acesso do tenant (`broker_id` nulo) ou de um corretor. | | [`access_profile_permissions`](#access_profile_permissions) | Permissões de um perfil. | | [`access_profile_templates`](#access_profile_templates) | Template de perfil do tenant, versionado: perfis ligados herdam as permissões ao vivo. | | [`access_profile_template_permissions`](#access_profile_template_permissions) | Permissões da versão atual de um template de tenant. | | [`access_profile_template_changes`](#access_profile_template_changes) | Cada edição confirmada de um template: versões de origem e destino, permissões adicionadas e removidas, motivo e autor. | | [`access_profile_template_change_decisions`](#access_profile_template_change_decisions) | Para cada perfil afetado por uma edição de template: acompanhou (`followed`) ou desvinculou (`detached`), e quantos usuários foram afetados. | | [`platform_tenant_blueprints`](#platform_tenant_blueprints) | Formato de tenant mantido pela plataforma; criar um tenant é escolher um blueprint. | | [`platform_access_profile_templates`](#platform_access_profile_templates) | Templates de perfil de um blueprint, copiados para `access_profile_templates` quando o tenant é criado. | | [`platform_access_profile_template_permissions`](#platform_access_profile_template_permissions) | Permissões de um template de plataforma. | | [`platform_access_profiles`](#platform_access_profiles) | Perfis acima dos tenants (por exemplo, suporte ou administração da plataforma). | | [`platform_access_profile_permissions`](#platform_access_profile_permissions) | Permissões de um perfil de plataforma; só permissões de escopo `platform`. | | [`platform_grants`](#platform_grants) | Operador de plataforma: usuário, perfil de plataforma, quem concedeu e por quê. | | [`platform_invitations`](#platform_invitations) | Convite para operar a plataforma. | | [`platform_invitation_deliveries`](#platform_invitation_deliveries) | Cada tentativa de envio de um convite de plataforma; reenvio é uma linha nova. | | [`platform_invitation_delivery_events`](#platform_invitation_delivery_events) | Linha do tempo bruta do provedor de envio; um webhook repetido não duplica (`dedup_key`). | | [`session_contexts`](#session_contexts) | Operador de plataforma atuando dentro de um tenant, com motivo. | ## Referência [#referência] ### `permissions` [#permissions] Projeção do registry de permissões gerado pelos `@Requires` dos use cases: código `..`, escopo e descrição. Uma permissão que sai do código recebe `deprecated_at`; nunca é apagada. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `code` | `text` | não | `..` | | `scope` | `text` | não | platform \| tenant \| broker | | `description` | `text` | não | | | `replaces_code` | `text` | sim | rename explícito | | `introduced_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `deprecated_at` | `timestamptz` | sim | saiu do código; nunca DELETE | **Chaves estrangeiras** - nenhuma Referenciada por: `access_profile_permissions`, `access_profile_template_permissions`, `permission_use_cases`, `platform_access_profile_permissions`, `platform_access_profile_template_permissions`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `permissions_scope_check` | `CHECK ((scope IN ('platform', 'tenant', 'broker')))` | | PK | `permissions_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `permissions_code_unique` | `UNIQUE (code)` | | Trigger | `permissions_scope_ceiling` | teto de escopo das permissões ([detalhes](/garantias/teto-de-escopo)) | **Fora do banco** - O seeder de permissões faz upsert por `code` e atualiza `description` e `scope`. Marcar `deprecated_at`, tratar `replaces_code` e registrar `permission_changes` ficam para a sincronização de deploy (NOVA-37). ### `permission_use_cases` [#permission_use_cases] Quais use cases exigem cada permissão; sincronizado no deploy. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `permission_id` | `bigint` | não | | | `use_case` | `text` | não | | **Chaves estrangeiras** - `permission_id` → `permissions.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `permission_use_cases_pkey` | `PRIMARY KEY (permission_id, use_case)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `permission_changes` [#permission_changes] Auditoria prevista de mudanças no catálogo de permissões (commit, autor, aprovador, quem fez o deploy). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `permission_code` | `text` | não | | | `change` | `text` | não | added \| modified \| deprecated \| renamed | | `before` | `jsonb` | sim | | | `after` | `jsonb` | sim | | | `commit_sha` | `text` | não | | | `commit_author` | `text` | não | do git: declarativo | | `approved_by` | `text` | sim | aprovador do PR | | `deployed_by` | `text` | não | identidade do CI: a confiável | | `recorded_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `permission_changes_change_check` | `CHECK ((change IN ('added', 'modified', 'deprecated', 'renamed')))` | | PK | `permission_changes_pkey` | `PRIMARY KEY (id)` | **Fora do banco** - Nenhum código grava nesta tabela hoje: o registro de mudanças fica para a sincronização de deploy (NOVA-37). ### `access_profiles` [#access_profiles] Perfil de acesso do tenant (`broker_id` nulo) ou de um corretor. `name` é só rótulo; o que vale é a lista de permissões. Pode seguir um template (`template_id`, `template_version`) ou ter sido desvinculado (`detached_at`). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `broker_id` | `bigint` | sim | nulo = perfil do tenant | | `scope` | `text` | não | gerada: `CASE WHEN (broker_id IS NULL) THEN 'tenant' ELSE 'broker' END` | | `name` | `text` | não | rótulo de tela; não tem efeito | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `template_id` | `bigint` | sim | ligado ao template (herda ao vivo) | | `template_version` | `integer` | sim | versão que segue | | `detached_at` | `timestamptz` | sim | fork: passa a usar permissões próprias | | `detached_by_user_id` | `bigint` | sim | | **Chaves estrangeiras** - `(template_id, tenant_id, scope)` → `access_profile_templates (id, tenant_id, scope)` (mesmo tenant) - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `detached_by_user_id` → `users.id` - `tenant_id` → `tenants.id` Referenciada por: `access_profile_permissions`, `access_profile_template_change_decisions`, `invitation_grants`, `membership_access_profiles`, `membership_brokers`, `membership_teams`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `access_profiles_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `access_profiles_id_tenant_broker_key` | `UNIQUE (id, tenant_id, broker_id)` | | UNIQUE | `access_profiles_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `access_profiles_id_tenant_scope_key` | `UNIQUE (id, tenant_id, scope)` | | UNIQUE (índice) | `access_profiles_name_key` | `(tenant_id, broker_id, name) NULLS NOT DISTINCT` | | Trigger | `access_profiles_scope_ceiling` | teto de escopo das permissões ([detalhes](/garantias/teto-de-escopo)) | | Coluna gerada | `scope` | `STORED` | **Fora do banco** - Quem concede só concede o que tem, sem autoconcessão: regra das rotas, não do banco. ### `access_profile_permissions` [#access_profile_permissions] Permissões de um perfil. Perfil do tenant aceita permissões `tenant` e `broker`; perfil de corretor, só `broker`. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `access_profile_id` | `bigint` | não | | | `permission_id` | `bigint` | não | | **Chaves estrangeiras** - `access_profile_id` → `access_profiles.id` - `permission_id` → `permissions.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `access_profile_permissions_pkey` | `PRIMARY KEY (access_profile_id, permission_id)` | | Trigger | `access_profile_permissions_scope_ceiling` | teto de escopo das permissões ([detalhes](/garantias/teto-de-escopo)) | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `access_profile_templates` [#access_profile_templates] Template de perfil do tenant, versionado: perfis ligados herdam as permissões ao vivo. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `scope` | `text` | não | tenant \| broker | | `name` | `text` | não | | | `description` | `text` | não | | | `auto_assign` | `text` | sim | tenant_admin \| broker_master \| broker_member | | `version` | `integer` | não | sobe a cada edição confirmada | | `source_platform_template_id` | `bigint` | sim | origem histórica | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `source_platform_template_id` → `platform_access_profile_templates.id` - `tenant_id` → `tenants.id` Referenciada por: `access_profile_template_changes`, `access_profile_template_permissions`, `access_profiles`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `access_profile_templates_auto_assign_check` | `CHECK ((auto_assign IN ('tenant_admin', 'broker_master', 'broker_member')))` | | CHECK | `access_profile_templates_scope_check` | `CHECK ((scope IN ('tenant', 'broker')))` | | PK | `access_profile_templates_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `access_profile_templates_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `access_profile_templates_id_tenant_scope_key` | `UNIQUE (id, tenant_id, scope)` | | Trigger | `access_profile_templates_scope_ceiling` | teto de escopo das permissões ([detalhes](/garantias/teto-de-escopo)) | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `access_profile_template_permissions` [#access_profile_template_permissions] Permissões da versão atual de um template de tenant. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `template_id` | `bigint` | não | | | `permission_id` | `bigint` | não | | **Chaves estrangeiras** - `permission_id` → `permissions.id` - `template_id` → `access_profile_templates.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `access_profile_template_permissions_pkey` | `PRIMARY KEY (template_id, permission_id)` | | Trigger | `access_profile_template_permissions_scope_ceiling` | teto de escopo das permissões ([detalhes](/garantias/teto-de-escopo)) | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `access_profile_template_changes` [#access_profile_template_changes] Cada edição confirmada de um template: versões de origem e destino, permissões adicionadas e removidas, motivo e autor. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `template_id` | `bigint` | não | | | `from_version` | `integer` | não | | | `to_version` | `integer` | não | | | `added_permission_ids` | `bigint[]` | não | | | `removed_permission_ids` | `bigint[]` | não | | | `reason` | `text` | não | | | `changed_by_user_id` | `bigint` | não | | | `changed_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(template_id, tenant_id)` → `access_profile_templates (id, tenant_id)` (mesmo tenant) - `changed_by_user_id` → `users.id` Referenciada por: `access_profile_template_change_decisions`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `access_profile_template_changes_pkey` | `PRIMARY KEY (id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `access_profile_template_change_decisions` [#access_profile_template_change_decisions] Para cada perfil afetado por uma edição de template: acompanhou (`followed`) ou desvinculou (`detached`), e quantos usuários foram afetados. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `change_id` | `bigint` | não | | | `access_profile_id` | `bigint` | não | | | `decision` | `text` | não | followed \| detached | | `affected_users` | `integer` | não | | **Chaves estrangeiras** - `access_profile_id` → `access_profiles.id` - `change_id` → `access_profile_template_changes.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `access_profile_template_change_decisions_decision_check` | `CHECK ((decision IN ('followed', 'detached')))` | | PK | `access_profile_template_change_decisions_pkey` | `PRIMARY KEY (change_id, access_profile_id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `platform_tenant_blueprints` [#platform_tenant_blueprints] Formato de tenant mantido pela plataforma; criar um tenant é escolher um blueprint. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `name` | `text` | não | | | `description` | `text` | não | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma Referenciada por: `platform_access_profile_templates`, `tenants`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `platform_tenant_blueprints_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `platform_tenant_blueprints_name_unique` | `UNIQUE (name)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `platform_access_profile_templates` [#platform_access_profile_templates] Templates de perfil de um blueprint, copiados para `access_profile_templates` quando o tenant é criado. Mudar aqui não afeta tenants existentes. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `blueprint_id` | `bigint` | não | | | `scope` | `text` | não | tenant \| broker | | `name` | `text` | não | | | `description` | `text` | não | | | `auto_assign` | `text` | sim | tenant_admin \| broker_master \| broker_member | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `blueprint_id` → `platform_tenant_blueprints.id` Referenciada por: `access_profile_templates`, `platform_access_profile_template_permissions`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_access_profile_templates_auto_assign_check` | `CHECK ((auto_assign IN ('tenant_admin', 'broker_master', 'broker_member')))` | | CHECK | `platform_access_profile_templates_scope_check` | `CHECK ((scope IN ('tenant', 'broker')))` | | PK | `platform_access_profile_templates_pkey` | `PRIMARY KEY (id)` | | Trigger | `platform_access_profile_templates_scope_ceiling` | teto de escopo das permissões ([detalhes](/garantias/teto-de-escopo)) | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `platform_access_profile_template_permissions` [#platform_access_profile_template_permissions] Permissões de um template de plataforma. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `template_id` | `bigint` | não | | | `permission_id` | `bigint` | não | | **Chaves estrangeiras** - `permission_id` → `permissions.id` - `template_id` → `platform_access_profile_templates.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `platform_access_profile_template_permissions_pkey` | `PRIMARY KEY (template_id, permission_id)` | | Trigger | `platform_access_profile_template_permissions_scope_ceiling` | teto de escopo das permissões ([detalhes](/garantias/teto-de-escopo)) | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `platform_access_profiles` [#platform_access_profiles] Perfis acima dos tenants (por exemplo, suporte ou administração da plataforma). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `name` | `text` | não | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma Referenciada por: `platform_access_profile_permissions`, `platform_grants`, `platform_invitations`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `platform_access_profiles_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `platform_access_profiles_name_unique` | `UNIQUE (name)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `platform_access_profile_permissions` [#platform_access_profile_permissions] Permissões de um perfil de plataforma; só permissões de escopo `platform`. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `platform_access_profile_id` | `bigint` | não | | | `permission_id` | `bigint` | não | | **Chaves estrangeiras** - `permission_id` → `permissions.id` - `platform_access_profile_id` → `platform_access_profiles.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `platform_access_profile_permissions_pkey` | `PRIMARY KEY (platform_access_profile_id, permission_id)` | | Trigger | `platform_access_profile_permissions_scope_ceiling` | teto de escopo das permissões ([detalhes](/garantias/teto-de-escopo)) | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `platform_grants` [#platform_grants] Operador de plataforma: usuário, perfil de plataforma, quem concedeu e por quê. Todo grant nasce de um convite de plataforma; o primeiro, do bootstrap. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `user_id` | `bigint` | não | | | `platform_access_profile_id` | `bigint` | não | | | `granted_by_user_id` | `bigint` | não | | | `granted_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `reason` | `text` | não | | | `revoked_by_user_id` | `bigint` | sim | | | `revoked_at` | `timestamptz` | sim | | | `origin_platform_invitation_id` | `bigint` | não | todo grant nasce de um convite | **Chaves estrangeiras** - `granted_by_user_id` → `users.id` - `origin_platform_invitation_id` → `platform_invitations.id` - `platform_access_profile_id` → `platform_access_profiles.id` - `revoked_by_user_id` → `users.id` - `user_id` → `users.id` Referenciada por: `session_contexts`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `platform_grants_pkey` | `PRIMARY KEY (id)` | | UNIQUE parcial | `platform_grants_live_key` | `(user_id) WHERE (revoked_at IS NULL)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `platform_invitations` [#platform_invitations] Convite para operar a plataforma. Guarda só o SHA-256 do token; tem exatamente um criador (usuário ou processo) e sempre expira. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `channel` | `text` | não | email \| phone | | `destination` | `text` | não | | | `destination_normalized` | `text` | não | | | `token_sha256` | `bytea` | não | | | `platform_access_profile_id` | `bigint` | não | | | `status` | `text` | não | pending \| accepted \| revoked \| expired | | `expires_at` | `timestamptz` | não | | | `created_by_user_id` | `bigint` | sim | ação humana | | `created_by_process` | `text` | sim | seed \| … | | `accepted_at` | `timestamptz` | sim | | | `accepted_by_user_id` | `bigint` | sim | | | `revoked_at` | `timestamptz` | sim | | | `revoked_by_user_id` | `bigint` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `accepted_by_user_id` → `users.id` - `created_by_user_id` → `users.id` - `platform_access_profile_id` → `platform_access_profiles.id` - `revoked_by_user_id` → `users.id` Referenciada por: `platform_grants`, `platform_invitation_deliveries`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_invitations_channel_check` | `CHECK ((channel IN ('email', 'phone')))` | | CHECK | `platform_invitations_creator_check` | `CHECK ((num_nonnulls(created_by_user_id, created_by_process) = 1))` | | CHECK | `platform_invitations_status_check` | `CHECK ((status IN ('pending', 'accepted', 'revoked', 'expired')))` | | PK | `platform_invitations_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `platform_invitations_token_sha256_unique` | `UNIQUE (token_sha256)` | | UNIQUE | `platform_invitations_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `platform_invitation_deliveries` [#platform_invitation_deliveries] Cada tentativa de envio de um convite de plataforma; reenvio é uma linha nova. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `platform_invitation_id` | `bigint` | não | | | `channel` | `text` | não | email \| sms \| whatsapp | | `destination_normalized` | `text` | não | foto no envio | | `provider` | `text` | não | | | `provider_message_id` | `text` | sim | | | `status` | `text` | não | queued \| sent \| delivered \| opened \| clicked \| bounced \| failed \| complained | | `queued_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `sent_at` | `timestamptz` | sim | | | `delivered_at` | `timestamptz` | sim | | | `opened_at` | `timestamptz` | sim | | | `failed_at` | `timestamptz` | sim | | | `failure_reason` | `text` | sim | | | `requested_by_user_id` | `bigint` | sim | | | `requested_by_process` | `text` | sim | | **Chaves estrangeiras** - `platform_invitation_id` → `platform_invitations.id` - `requested_by_user_id` → `users.id` Referenciada por: `platform_invitation_delivery_events`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_invitation_deliveries_channel_check` | `CHECK ((channel IN ('email', 'sms', 'whatsapp')))` | | CHECK | `platform_invitation_deliveries_requester_check` | `CHECK ((num_nonnulls(requested_by_user_id, requested_by_process) = 1))` | | CHECK | `platform_invitation_deliveries_status_check` | `CHECK ((status IN ('queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'failed', 'complained')))` | | PK | `platform_invitation_deliveries_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `platform_invitation_deliveries_provider_message_key` | `UNIQUE (provider, provider_message_id)` | | UNIQUE | `platform_invitation_deliveries_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `platform_invitation_delivery_events` [#platform_invitation_delivery_events] Linha do tempo bruta do provedor de envio; um webhook repetido não duplica (`dedup_key`). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `delivery_id` | `bigint` | não | | | `status` | `text` | não | | | `occurred_at` | `timestamptz` | não | horário do provedor, em UTC | | `received_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `dedup_key` | `text` | não | id do evento ou sha256 do payload | | `payload` | `jsonb` | não | padrão `'{}'::jsonb`; webhook como chegou, sem token | **Chaves estrangeiras** - `delivery_id` → `platform_invitation_deliveries.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_invitation_delivery_events_payload_size_check` | `CHECK ((pg_column_size(payload) < 64000))` | | PK | `platform_invitation_delivery_events_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `platform_invitation_delivery_events_dedup_key` | `UNIQUE (delivery_id, dedup_key)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `session_contexts` [#session_contexts] Operador de plataforma atuando dentro de um tenant, com motivo. Não é impersonação: o ator continua sendo o usuário real. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `session_id` | `bigint` | não | | | `platform_grant_id` | `bigint` | não | | | `tenant_id` | `bigint` | não | | | `reason` | `text` | não | | | `entered_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `left_at` | `timestamptz` | sim | | **Chaves estrangeiras** - `platform_grant_id` → `platform_grants.id` - `session_id` → `sessions.id` - `tenant_id` → `tenants.id` Referenciada por: `invitation_session_contexts`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `session_contexts_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `session_contexts_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE parcial | `session_contexts_live_key` | `(session_id) WHERE (left_at IS NULL)` | **Fora do banco** - Hoje nenhuma sessão é criada (autenticação em reconstrução), então a tabela fica vazia. # Bank Users (/modelo-de-dados/bank-users) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} Um usuário bancário (`user_banks`) é global e sensível: pessoa × IF, com a senha guardada em Credentials. Nenhuma rota deve lê-lo diretamente; o acesso passa pela atribuição (`bank_user_assignments`), que tem tenant e corretor. Transferir o usuário bancário entre corretores do mesmo tenant fecha uma atribuição e abre outra, com registro em `bank_user_transfers`. A revelação da senha acontece só no portal e é registrada uma vez por versão em `bank_credential_deliveries`. ```mermaid erDiagram users ||--o{ user_banks : "na IF" credentials ||--|| user_banks : "senha" user_banks ||--o{ bank_user_assignments : "atribuições" membership_brokers ||--o{ bank_user_assignments : "origem" bank_user_assignments ||--o{ bank_user_transfers : "de / para" credential_versions ||--o| bank_credential_deliveries : "revelada" ``` ## Tabelas (4) [#tabelas-4] | Tabela | Para quê | | --- | --- | | [`user_banks`](#user_banks) | O login de uma pessoa numa IF, com sua credencial; um aberto por (usuário, IF). | | [`bank_user_assignments`](#bank_user_assignments) | Atribuição de um usuário bancário a um corretor do tenant, com vigência por dia `[valid_from, valid_until)`, o código de loja sob o qual foi criado e a atuação de origem (`origin_membership_broker_id`). | | [`bank_user_transfers`](#bank_user_transfers) | Registro da transferência de um usuário bancário entre corretores do mesmo tenant: atribuição de origem e de destino, data efetiva, ator e motivo. | | [`bank_credential_deliveries`](#bank_credential_deliveries) | Revelação da senha bancária no portal: uma por versão da credencial (a PK é `credential_version_id`), com quem viu e quando (`clock_timestamp()`). | ## Referência [#referência] ### `user_banks` [#user_banks] O login de uma pessoa numa IF, com sua credencial; um aberto por (usuário, IF). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `user_id` | `bigint` | não | | | `financial_institution_id` | `bigint` | não | | | `credential_id` | `bigint` | não | | | `kind` | `text` | não | | | `status` | `text` | não | | | `confirmed_at` | `timestamptz` | não | | | `closed_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `credential_id` → `credentials.id` - `financial_institution_id` → `financial_institutions.id` - `user_id` → `users.id` Referenciada por: `bank_credential_deliveries`, `bank_user_assignments`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `user_banks_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `user_banks_credential_key` | `UNIQUE (credential_id)` | | UNIQUE | `user_banks_id_credential_key` | `UNIQUE (id, credential_id)` | | UNIQUE | `user_banks_id_user_institution_key` | `UNIQUE (id, user_id, financial_institution_id)` | | UNIQUE parcial | `user_banks_user_if_open_key` | `(user_id, financial_institution_id) WHERE (closed_at IS NULL)` | **Fora do banco** - `kind` e `status` não têm CHECK de valores. - O número do usuário bancário não se repetir entre tenants é tratado na aplicação, que responde com erro neutro. ### `bank_user_assignments` [#bank_user_assignments] Atribuição de um usuário bancário a um corretor do tenant, com vigência por dia `[valid_from, valid_until)`, o código de loja sob o qual foi criado e a atuação de origem (`origin_membership_broker_id`). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `user_bank_id` | `bigint` | não | | | `user_id` | `bigint` | não | | | `party_id` | `bigint` | não | | | `financial_institution_id` | `bigint` | não | | | `tenant_id` | `bigint` | não | | | `broker_id` | `bigint` | não | | | `valid_from` | `date` | não | vigência por dia | | `valid_until` | `date` | sim | exclusivo [valid_from, valid_until) | | `created_by_user_id` | `bigint` | sim | | | `ended_by_user_id` | `bigint` | sim | | | `change_reason` | `text` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `ended_recorded_at` | `timestamptz` | sim | | | `store_code_id` | `bigint` | não | código de loja sob o qual o usuário foi criado | | `origin_membership_broker_id` | `bigint` | não | atuação de origem no corretor (obrigatória) | **Chaves estrangeiras** - `(origin_membership_broker_id, tenant_id, broker_id, user_id)` → `membership_brokers (id, tenant_id, broker_id, user_id)` (mesmo tenant) - `(store_code_id, financial_institution_id, tenant_id)` → `store_codes (id, financial_institution_id, tenant_id)` (mesmo tenant) - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `(user_bank_id, user_id, financial_institution_id)` → `user_banks (id, user_id, financial_institution_id)` - `(user_id, party_id)` → `users (id, party_id)` - `created_by_user_id` → `users.id` - `ended_by_user_id` → `users.id` Referenciada por: `bank_credential_deliveries`, `bank_user_transfers`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `bank_assignments_valid_period_check` | `CHECK (((valid_until IS NULL) OR (valid_until > valid_from)))` | | PK | `bank_user_assignments_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `bank_user_assignments_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `bank_user_assignments_id_user_bank_key` | `UNIQUE (id, user_bank_id)` | | UNIQUE | `bank_user_assignments_id_user_bank_tenant_broker_key` | `UNIQUE (id, user_bank_id, tenant_id, broker_id)` | | UNIQUE parcial | `bank_assignments_one_open_key` | `(user_bank_id) WHERE (valid_until IS NULL)` | **Fora do banco** - Não há EXCLUDE de sobreposição: só uma atribuição aberta por usuário bancário é garantida. - Coerência de `party_id` entre a atribuição e a atuação de origem é regra da aplicação. ### `bank_user_transfers` [#bank_user_transfers] Registro da transferência de um usuário bancário entre corretores do mesmo tenant: atribuição de origem e de destino, data efetiva, ator e motivo. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `user_bank_id` | `bigint` | não | | | `from_assignment_id` | `bigint` | não | | | `from_broker_id` | `bigint` | não | | | `to_assignment_id` | `bigint` | não | | | `to_broker_id` | `bigint` | não | | | `effective_on` | `date` | não | troca vale a partir deste dia | | `actor_user_id` | `bigint` | não | | | `actor_membership_id` | `bigint` | sim | | | `actor_broker_id` | `bigint` | sim | | | `reason` | `text` | não | | | `recorded_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(actor_membership_id, actor_user_id, tenant_id)` → `memberships (id, user_id, tenant_id)` (mesmo tenant) - `(from_assignment_id, user_bank_id, tenant_id, from_broker_id)` → `bank_user_assignments (id, user_bank_id, tenant_id, broker_id)` (mesmo tenant) - `(tenant_id, actor_broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `(to_assignment_id, user_bank_id, tenant_id, to_broker_id)` → `bank_user_assignments (id, user_bank_id, tenant_id, broker_id)` (mesmo tenant) - `actor_user_id` → `users.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `bank_transfers_distinct_assignments_check` | `CHECK ((from_assignment_id <> to_assignment_id))` | | PK | `bank_user_transfers_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `bank_user_transfers_from_assignment_key` | `UNIQUE (from_assignment_id)` | | UNIQUE | `bank_user_transfers_to_assignment_key` | `UNIQUE (to_assignment_id)` | **Fora do banco** - Fechar a origem e abrir o destino na mesma data, numa única transação, é regra da aplicação. ### `bank_credential_deliveries` [#bank_credential_deliveries] Revelação da senha bancária no portal: uma por versão da credencial (a PK é `credential_version_id`), com quem viu e quando (`clock_timestamp()`). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `credential_version_id` | `bigint` | não | | | `credential_id` | `bigint` | não | | | `user_bank_id` | `bigint` | não | | | `assignment_id` | `bigint` | não | | | `consumed_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `viewed_by_user_id` | `bigint` | não | | | `tenant_id` | `bigint` | não | segredo dentro do tenant | **Chaves estrangeiras** - `(assignment_id, tenant_id)` → `bank_user_assignments (id, tenant_id)` (mesmo tenant) - `(assignment_id, user_bank_id)` → `bank_user_assignments (id, user_bank_id)` - `(credential_version_id, credential_id)` → `credential_versions (id, credential_id)` - `(user_bank_id, credential_id)` → `user_banks (id, credential_id)` - `viewed_by_user_id` → `users.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `bank_credential_deliveries_pkey` | `PRIMARY KEY (credential_version_id)` | **Fora do banco** - `viewed_by_user_id` vem da identidade autenticada no servidor, nunca do payload; a senha é devolvida só depois do commit. # Brokers (/modelo-de-dados/brokers) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} O corretor é global (uma party); o que o tenant conhece é `tenant_brokers`. Quase todas as tabelas seguintes referenciam o par `(tenant_id, broker_id)` de `tenant_brokers`, e não `brokers` direto, para que o corretor seja sempre um corretor **daquele** tenant. Códigos de loja (`store_codes`) pertencem a exatamente um dono: a conta do grupo na IF ou o próprio corretor. `broker_store_codes` diz qual código o corretor usa em cada IF, em qual regime e em qual período, sem sobreposição. ```mermaid erDiagram brokers ||--o{ tenant_brokers : "no tenant" tenant_brokers ||--o{ broker_affiliations : "hierarquia" tenant_brokers ||--o{ tenant_broker_accounts : "termos" tenant_brokers ||--o{ store_codes : "código próprio" financial_institution_accounts ||--o{ store_codes : "código do grupo" store_codes ||--o{ broker_store_codes : "usado por" ``` ## Tabelas (6) [#tabelas-6] | Tabela | Para quê | | --- | --- | | [`brokers`](#brokers) | Identidade global do corretor (party), com identificador público. | | [`tenant_brokers`](#tenant_brokers) | O corretor dentro de um tenant, com código no tenant e desativação. | | [`broker_affiliations`](#broker_affiliations) | Histórico da hierarquia entre corretores do mesmo tenant (corretor e corretor-pai), com início e fim; uma afiliação aberta por corretor. | | [`tenant_broker_accounts`](#tenant_broker_accounts) | Com quais CNPJs do grupo o corretor tem termo assinado (N:N), com vigência. | | [`store_codes`](#store_codes) | Registro dos códigos de loja e de seu dono: a conta do grupo na IF ou o corretor. | | [`broker_store_codes`](#broker_store_codes) | Qual código de loja o corretor usa em cada IF e em qual regime, com vigência `[valid_from, valid_until)` sem sobreposição. | ## Referência [#referência] ### `brokers` [#brokers] Identidade global do corretor (party), com identificador público. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `party_id` | `bigint` | não | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `party_id` → `parties.id` Referenciada por: `tenant_brokers`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `brokers_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `brokers_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `tenant_brokers` [#tenant_brokers] O corretor dentro de um tenant, com código no tenant e desativação. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `broker_id` | `bigint` | não | | | `broker_code` | `text` | sim | | | `disabled_at` | `timestamptz` | sim | | | `disabled_reason` | `text` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `broker_id` → `brokers.id` - `tenant_id` → `tenants.id` Referenciada por: `access_profiles`, `bank_user_assignments`, `bank_user_transfers`, `broker_affiliations`, `broker_prospects`, `broker_store_codes`, `invitation_grants`, `membership_brokers`, `membership_portfolios`, `store_codes`, `tenant_broker_accounts`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `tenant_brokers_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `tenant_brokers_tenant_broker_key` | `UNIQUE (tenant_id, broker_id)` | | UNIQUE | `tenant_brokers_tenant_code_key` | `UNIQUE (tenant_id, broker_code)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `broker_affiliations` [#broker_affiliations] Histórico da hierarquia entre corretores do mesmo tenant (corretor e corretor-pai), com início e fim; uma afiliação aberta por corretor. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `tenant_id` | `bigint` | não | | | `broker_id` | `bigint` | não | | | `parent_id` | `bigint` | não | | | `started_on` | `date` | não | | | `ended_on` | `date` | sim | | | `ended_by` | `bigint` | sim | | | `ended_reason` | `text` | sim | | | `approved_by` | `bigint` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `(tenant_id, parent_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `approved_by` → `users.id` - `ended_by` → `users.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `broker_affiliations_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `broker_affiliations_uid_unique` | `UNIQUE (uid)` | | UNIQUE parcial | `broker_affiliations_live_key` | `(tenant_id, broker_id) WHERE (ended_on IS NULL)` | **Fora do banco** - Ausência de ciclos na hierarquia e `ended_on` posterior a `started_on` não são verificados pelo banco. ### `tenant_broker_accounts` [#tenant_broker_accounts] Com quais CNPJs do grupo o corretor tem termo assinado (N:N), com vigência. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `broker_id` | `bigint` | não | | | `tenant_account_id` | `bigint` | não | | | `signed_on` | `date` | sim | termo assinado com esse CNPJ | | `valid_from` | `date` | não | | | `valid_until` | `date` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(tenant_account_id, tenant_id)` → `tenant_accounts (id, tenant_id)` (mesmo tenant) - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `tenant_broker_accounts_pkey` | `PRIMARY KEY (id)` | | UNIQUE parcial | `tenant_broker_accounts_live_key` | `(tenant_id, broker_id, tenant_account_id) WHERE (valid_until IS NULL)` | **Fora do banco** - Não há CHECK de ordem entre `valid_from` e `valid_until`, nem EXCLUDE de sobreposição; só um termo aberto por trio é garantido. ### `store_codes` [#store_codes] Registro dos códigos de loja e de seu dono: a conta do grupo na IF ou o corretor. O código é único por (tenant, IF), preservando zeros à esquerda; pode repetir entre tenants. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `financial_institution_id` | `bigint` | não | | | `code` | `text` | não | como o banco manda; zeros à esquerda preservados | | `financial_institution_account_id` | `bigint` | sim | dono = CNPJ do grupo | | `broker_id` | `bigint` | sim | dono = corretor (código próprio) | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(financial_institution_account_id, financial_institution_id, tenant_id)` → `financial_institution_accounts (id, financial_institution_id, tenant_id)` (mesmo tenant) - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `financial_institution_id` → `financial_institutions.id` Referenciada por: `bank_user_assignments`, `broker_store_codes`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `store_codes_one_owner_check` | `CHECK ((num_nonnulls(financial_institution_account_id, broker_id) = 1))` | | PK | `store_codes_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `store_codes_id_institution_tenant_key` | `UNIQUE (id, financial_institution_id, tenant_id)` | | UNIQUE | `store_codes_id_tenant_institution_key` | `UNIQUE (id, tenant_id, financial_institution_id)` | | UNIQUE | `store_codes_tenant_institution_code_key` | `UNIQUE (tenant_id, financial_institution_id, code)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `broker_store_codes` [#broker_store_codes] Qual código de loja o corretor usa em cada IF e em qual regime, com vigência `[valid_from, valid_until)` sem sobreposição. O sistema resolve o código a partir daqui; ele não é digitado. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `broker_id` | `bigint` | não | | | `financial_institution_id` | `bigint` | não | | | `store_code_id` | `bigint` | não | | | `regime` | `text` | não | standard \| substabelecido \| indicado \| subzero | | `valid_from` | `date` | não | | | `valid_until` | `date` | sim | exclusivo | | `assigned_by_user_id` | `bigint` | não | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(store_code_id, tenant_id, financial_institution_id)` → `store_codes (id, tenant_id, financial_institution_id)` (mesmo tenant) - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `assigned_by_user_id` → `users.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `broker_store_codes_period_check` | `CHECK (((valid_until IS NULL) OR (valid_until > valid_from)))` | | CHECK | `broker_store_codes_regime_check` | `CHECK ((regime IN ('standard', 'substabelecido', 'indicado', 'subzero')))` | | PK | `broker_store_codes_pkey` | `PRIMARY KEY (id)` | | EXCLUDE | `broker_store_codes_no_overlap` | `EXCLUDE USING gist (tenant_id WITH =, broker_id WITH =, financial_institution_id WITH =, daterange(valid_from, valid_until, '[)') WITH &&)` | **Fora do banco** - Regime `standard` exige código da conta do grupo; os demais regimes exigem código do próprio corretor. É regra de domínio em código (`assertRegimeMatchesOwner`), não constraint. # Credentials (/modelo-de-dados/credentials) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} Credentials é domínio próprio porque três consumidores guardam segredo do mesmo jeito: o login do usuário (`user_credentials`), a credencial técnica de uma conta na instituição financeira (`financial_institution_credentials`) e o usuário bancário (`user_banks`). Uma credencial tem versões; trocar a senha cria uma versão nova, e no máximo uma fica ativa. ```mermaid erDiagram credentials ||--o{ credential_versions : "versões" users ||--o{ credential_versions : "criou" ``` ## Tabelas (2) [#tabelas-2] | Tabela | Para quê | | --- | --- | | [`credentials`](#credentials) | A credencial em si: método (`method`), identificador externo e ciclo de vida (expiração, último uso, revogação). | | [`credential_versions`](#credential_versions) | Cada versão do segredo: hash ou texto cifrado, quem criou e por quê, e o estado da versão. | ## Referência [#referência] ### `credentials` [#credentials] A credencial em si: método (`method`), identificador externo e ciclo de vida (expiração, último uso, revogação). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `method` | `text` | não | | | `external_id` | `text` | sim | | | `attributes` | `jsonb` | não | padrão `'{}'::jsonb` | | `expires_at` | `timestamptz` | sim | | | `last_used_at` | `timestamptz` | sim | | | `revoked_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma Referenciada por: `credential_versions`, `financial_institution_credentials`, `user_banks`, `user_credentials`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `credentials_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `credentials_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - `method` não tem CHECK; os métodos aceitos são definidos na aplicação. ### `credential_versions` [#credential_versions] Cada versão do segredo: hash ou texto cifrado, quem criou e por quê, e o estado da versão. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `credential_id` | `bigint` | não | | | `version` | `integer` | não | | | `identifier` | `text` | sim | | | `identifier_type` | `text` | sim | | | `secret_hash` | `text` | sim | | | `secret_ciphertext` | `bytea` | sim | | | `encryption_key_id` | `text` | sim | | | `status` | `text` | não | padrão `'pending'` | | `expires_at` | `timestamptz` | sim | | | `created_by_user_id` | `bigint` | sim | | | `created_by_service` | `text` | sim | | | `change_reason` | `text` | não | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `activated_at` | `timestamptz` | sim | | | `retired_at` | `timestamptz` | sim | | **Chaves estrangeiras** - `created_by_user_id` → `users.id` - `credential_id` → `credentials.id` Referenciada por: `bank_credential_deliveries`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `credential_versions_positive_version_check` | `CHECK ((version > 0))` | | PK | `credential_versions_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `credential_versions_credential_version_key` | `UNIQUE (credential_id, version)` | | UNIQUE | `credential_versions_id_credential_key` | `UNIQUE (id, credential_id)` | | UNIQUE parcial | `credential_versions_one_active_key` | `(credential_id) WHERE (status = 'active')` | **Fora do banco** - `status` não tem CHECK de valores; só a unicidade da versão `active` é garantida. - Exatamente um autor (`created_by_user_id` ou `created_by_service`) não é garantido por CHECK. # Financial Institutions (/modelo-de-dados/financial-institutions) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} A instituição financeira (IF) é global. O que é do tenant é o **vínculo** de um CNPJ do grupo com a IF (`financial_institution_accounts`). As credenciais técnicas, usadas pelo sistema para falar com a IF, pertencem a esse vínculo e não a uma loja; um vínculo pode ter várias. ```mermaid erDiagram financial_institutions ||--o{ financial_institution_accounts : "vínculos" tenant_accounts ||--o{ financial_institution_accounts : "CNPJ" financial_institution_accounts ||--o{ financial_institution_credentials : "credenciais" credentials ||--o| financial_institution_credentials : "segredo" ``` ## Tabelas (3) [#tabelas-3] | Tabela | Para quê | | --- | --- | | [`financial_institutions`](#financial_institutions) | Catálogo global de IFs, com código opcional e único. | | [`financial_institution_accounts`](#financial_institution_accounts) | Vínculo de um CNPJ do tenant com uma IF; único por par (CNPJ, IF). | | [`financial_institution_credentials`](#financial_institution_credentials) | Credencial técnica de um vínculo CNPJ × IF, por ambiente, no mesmo tenant do vínculo. | ## Referência [#referência] ### `financial_institutions` [#financial_institutions] Catálogo global de IFs, com código opcional e único. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `name` | `text` | não | | | `code` | `text` | sim | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma Referenciada por: `financial_institution_accounts`, `financial_institution_products`, `store_codes`, `user_banks`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `financial_institutions_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `financial_institutions_code_unique` | `UNIQUE (code)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `financial_institution_accounts` [#financial_institution_accounts] Vínculo de um CNPJ do tenant com uma IF; único por par (CNPJ, IF). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `tenant_account_id` | `bigint` | não | | | `financial_institution_id` | `bigint` | não | | | `status` | `text` | não | padrão `'draft'` | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(tenant_account_id, tenant_id)` → `tenant_accounts (id, tenant_id)` (mesmo tenant) - `financial_institution_id` → `financial_institutions.id` Referenciada por: `financial_institution_account_products`, `financial_institution_credentials`, `store_codes`, `team_financial_institution_accounts`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `financial_institution_accounts_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `financial_institution_accounts_account_institution_key` | `UNIQUE (tenant_account_id, financial_institution_id)` | | UNIQUE | `financial_institution_accounts_id_institution_tenant_key` | `UNIQUE (id, financial_institution_id, tenant_id)` | | UNIQUE | `financial_institution_accounts_id_tenant_key` | `UNIQUE (id, tenant_id)` | **Fora do banco** - `status` (padrão `draft`) não tem CHECK de valores. ### `financial_institution_credentials` [#financial_institution_credentials] Credencial técnica de um vínculo CNPJ × IF, por ambiente, no mesmo tenant do vínculo. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `credential_id` | `bigint` | não | | | `financial_institution_account_id` | `bigint` | não | | | `environment` | `text` | não | | | `label` | `text` | não | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `revoked_at` | `timestamptz` | sim | | | `tenant_id` | `bigint` | não | credencial técnica dentro do tenant | **Chaves estrangeiras** - `(financial_institution_account_id, tenant_id)` → `financial_institution_accounts (id, tenant_id)` (mesmo tenant) - `credential_id` → `credentials.id` - `financial_institution_account_id` → `financial_institution_accounts.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `financial_institution_credentials_pkey` | `PRIMARY KEY (credential_id)` | **Fora do banco** - `environment` não tem CHECK de valores. # Identity (/modelo-de-dados/identity) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} Um usuário é a identidade global de uma pessoa (uma party de pessoa física). Os identificadores de login vivem em duas tabelas: `user_logins`, por tenant, e `platform_logins`, sem tenant, para operadores de plataforma. A senha não fica aqui: `user_credentials` liga o usuário a uma credencial do domínio Credentials. As tabelas de sessão e refresh token existem no schema, mas **a autenticação está em reconstrução**: o login antigo foi removido e nenhuma rota cria sessão hoje (ver [Autenticação](/autenticacao)). Esta página descreve só o schema. ```mermaid erDiagram person_profiles ||--|| users : "é" users ||--o{ user_logins : "por tenant" users ||--o{ platform_logins : "sem tenant" users ||--o{ user_credentials : "senha" users ||--o{ sessions : "sessões" sessions ||--o{ refresh_tokens : "renovação" ``` ## Tabelas (6) [#tabelas-6] | Tabela | Para quê | | --- | --- | | [`users`](#users) | Identidade global de uma pessoa; no máximo um usuário por party. | | [`user_logins`](#user_logins) | Identificadores de login no namespace de um tenant (CPF, CNPJ, e-mail, código, username). | | [`platform_logins`](#platform_logins) | Identificadores de login do operador de plataforma (username ou e-mail), sem tenant; únicos globalmente enquanto ativos. | | [`user_credentials`](#user_credentials) | Liga um usuário às suas credenciais de login; uma credencial pertence a um único usuário. | | [`sessions`](#sessions) | Sessão autenticada: usuário, login e credencial usados, dados do cliente, expiração e revogação. | | [`refresh_tokens`](#refresh_tokens) | Tokens de renovação de uma sessão: só o SHA-256 é guardado, com uso único e encadeamento (`replaced_by`). | ## Referência [#referência] ### `users` [#users] Identidade global de uma pessoa; no máximo um usuário por party. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `party_id` | `bigint` | não | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `party_id` → `person_profiles.party_id` Referenciada por: `access_profile_template_changes`, `access_profiles`, `bank_credential_deliveries`, `bank_user_assignments`, `bank_user_transfers`, `broker_affiliations`, `broker_store_codes`, `credential_versions`, `invitation_deliveries`, `invitations`, `membership_access_profiles`, `membership_brokers`, `membership_portfolios`, `membership_teams`, `memberships`, `platform_grants`, `platform_invitation_deliveries`, `platform_invitations`, `platform_logins`, `sessions`, `task_run_user_actors`, `team_financial_institution_accounts`, `team_hierarchies`, `teams`, `user_banks`, `user_credentials`, `user_logins`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `users_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `users_id_party_key` | `UNIQUE (id, party_id)` | | UNIQUE | `users_party_key` | `UNIQUE (party_id)` | **Fora do banco** - Todo usuário nascer de um convite aceito é regra de negócio sem constraint no banco hoje. ### `user_logins` [#user_logins] Identificadores de login no namespace de um tenant (CPF, CNPJ, e-mail, código, username). O mesmo valor pode existir em tenants diferentes e não une pessoas. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `user_id` | `bigint` | não | | | `tenant_id` | `bigint` | não | namespace de autenticação | | `kind` | `text` | não | cpf \| cnpj \| email \| code \| username | | `value` | `text` | não | como digitado | | `value_normalized` | `text` | não | chave de busca | | `verified_at` | `timestamptz` | sim | e-mail/telefone verificado | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `revoked_at` | `timestamptz` | sim | | **Chaves estrangeiras** - `tenant_id` → `tenants.id` - `user_id` → `users.id` Referenciada por: `sessions`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `user_logins_kind_check` | `CHECK ((kind IN ('cpf', 'cnpj', 'email', 'code', 'username')))` | | PK | `user_logins_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `user_logins_id_user_key` | `UNIQUE (id, user_id)` | | UNIQUE parcial | `user_logins_live_key` | `(tenant_id, kind, value_normalized) WHERE (revoked_at IS NULL)` | **Fora do banco** - Normalização de `value_normalized` é feita no código (`login-normalization.ts`). ### `platform_logins` [#platform_logins] Identificadores de login do operador de plataforma (username ou e-mail), sem tenant; únicos globalmente enquanto ativos. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `user_id` | `bigint` | não | | | `kind` | `text` | não | username \| email | | `value` | `text` | não | como digitado | | `value_normalized` | `text` | não | chave de busca | | `verified_at` | `timestamptz` | sim | e-mail verificado | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `revoked_at` | `timestamptz` | sim | | **Chaves estrangeiras** - `user_id` → `users.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `platform_logins_kind_check` | `CHECK ((kind IN ('username', 'email')))` | | PK | `platform_logins_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `platform_logins_id_user_key` | `UNIQUE (id, user_id)` | | UNIQUE parcial | `platform_logins_live_key` | `(kind, value_normalized) WHERE (revoked_at IS NULL)` | **Fora do banco** - A ligação da sessão de plataforma com este login ainda não existe; vem com a reconstrução da autenticação. ### `user_credentials` [#user_credentials] Liga um usuário às suas credenciais de login; uma credencial pertence a um único usuário. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `user_id` | `bigint` | não | | | `credential_id` | `bigint` | não | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `revoked_at` | `timestamptz` | sim | | **Chaves estrangeiras** - `credential_id` → `credentials.id` - `user_id` → `users.id` Referenciada por: `sessions`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `user_credentials_pkey` | `PRIMARY KEY (user_id, credential_id)` | | UNIQUE | `user_credentials_credential_key` | `UNIQUE (credential_id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `sessions` [#sessions] Sessão autenticada: usuário, login e credencial usados, dados do cliente, expiração e revogação. Schema apenas: nenhuma rota cria sessão até a reconstrução da autenticação. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `user_id` | `bigint` | não | | | `login_id` | `bigint` | sim | login usado na autenticação de tenant; do mesmo usuário | | `credential_id` | `bigint` | sim | | | `created_ip` | `inet` | sim | | | `last_ip` | `inet` | sim | | | `user_agent` | `text` | sim | | | `client` | `text` | sim | | | `device_label` | `text` | sim | | | `mfa_at` | `timestamptz` | sim | | | `last_seen_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `idle_expires_at` | `timestamptz` | sim | | | `revoked_at` | `timestamptz` | sim | | | `revoked_reason` | `text` | sim | | | `revoked_by` | `bigint` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `expires_at` | `timestamptz` | não | | | `scope` | `text` | não | platform \| tenant | **Chaves estrangeiras** - `(login_id, user_id)` → `user_logins (id, user_id)` - `(user_id, credential_id)` → `user_credentials (user_id, credential_id)` - `revoked_by` → `users.id` - `user_id` → `users.id` Referenciada por: `refresh_tokens`, `session_contexts`, `session_memberships`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `sessions_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `sessions_id_user_key` | `UNIQUE (id, user_id)` | | UNIQUE | `sessions_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - `scope` (`platform` ou `tenant`) não tem CHECK. - Regras de expiração ociosa e de renovação pertencem à autenticação em reconstrução. ### `refresh_tokens` [#refresh_tokens] Tokens de renovação de uma sessão: só o SHA-256 é guardado, com uso único e encadeamento (`replaced_by`). Schema apenas, sem runtime hoje. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `session_id` | `bigint` | não | | | `token_sha256` | `bytea` | não | | | `issued_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `expires_at` | `timestamptz` | não | | | `used_at` | `timestamptz` | sim | | | `grace_until` | `timestamptz` | sim | reservado | | `replaced_by` | `bigint` | sim | | **Chaves estrangeiras** - `replaced_by` → `refresh_tokens.id` - `session_id` → `sessions.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `refresh_tokens_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `refresh_tokens_token_sha256_unique` | `UNIQUE (token_sha256)` | | UNIQUE parcial | `refresh_tokens_live_key` | `(session_id) WHERE (used_at IS NULL)` | **Fora do banco** - Detecção de reutilização e revogação da família dependem da autenticação em reconstrução. # Visão geral do modelo (/modelo-de-dados) O modelo de dados descreve 71 tabelas em 11 domínios. Cada domínio é um pacote (`packages/`) cujas entities geram o schema, e cada domínio tem a sua primeira migration, na ordem abaixo. A ordem é a direção das dependências: um domínio só referencia os anteriores. Quando uma FK precisa apontar para um domínio posterior, quem a cria é o domínio posterior (por exemplo, `tenants → platform_tenant_blueprints` é criada por Authorization). | # | Domínio | Tabelas | Migration | Depende de | | --- | --- | --- | --- | --- | | 1 | [Parties](/modelo-de-dados/parties) | 6 | `0001_parties` | — | | 2 | [Credentials](/modelo-de-dados/credentials) | 2 | `0002_credentials` | — | | 3 | [Tenancy](/modelo-de-dados/tenancy) | 2 | `0003_tenancy` | Parties | | 4 | [Identity](/modelo-de-dados/identity) | 6 | `0004_identity`, `0014_identity-platform-logins` | Parties, Credentials, Tenancy | | 5 | [Financial Institutions](/modelo-de-dados/financial-institutions) | 3 | `0005_financial-institutions` | Tenancy, Credentials | | 6 | [Product Catalog](/modelo-de-dados/product-catalog) | 5 | `0006_product-catalog` | Financial Institutions | | 7 | [Brokers](/modelo-de-dados/brokers) | 6 | `0007_brokers` | Parties, Tenancy, Financial Institutions | | 8 | [Authorization](/modelo-de-dados/authorization) | 19 | `0008_authorization`, `0012`, `0013`, `0017` (teto de escopo) | Identity, Tenancy, Brokers | | 9 | [Workforce](/modelo-de-dados/workforce) | 17 | `0009_workforce` | Authorization e anteriores | | 10 | [Onboarding](/modelo-de-dados/onboarding) | 1 | `0010_onboarding` | Parties, Brokers, Workforce | | 11 | [Bank Users](/modelo-de-dados/bank-users) | 4 | `0011_bank-users` | Identity, Workforce, Brokers, Financial Institutions, Credentials | Depois dos domínios vêm as migrations de infraestrutura, fora do modelo de dados: `0015_idempotency` (2 tabelas, ver [Idempotência](/background/idempotencia)) e `0016_work` (8 tabelas, ver [Background work](/background)). ## Regras de fronteira [#regras-de-fronteira] - Parties, Credentials e Identity não conhecem tenant. - Toda tabela de tenant carrega `tenant_id`. Quando uma linha aponta para outra que também é de tenant, a FK é composta e inclui `tenant_id` dos dois lados, então as duas pontas ficam sempre no mesmo tenant (ver [Garantias do banco](/garantias)). - Trabalho acima dos tenants (plataforma) mora em tabelas próprias `platform_*`, nunca em `tenant_id` nulo ou com valor sentinela. - Quando uma coluna só faria sentido para alguns casos, o modelo prefere uma tabela própria a uma coluna anulável (por exemplo, `invitation_session_contexts`). ## Como ler as páginas de domínio [#como-ler-as-páginas-de-domínio] Cada página traz um diagrama ER pequeno com as relações principais e, para cada tabela: - **colunas**, com tipo, nulidade, padrão e as notas do modelo; - **chaves estrangeiras**, com as colunas dos dois lados (FKs compostas aparecem inteiras) e quem referencia a tabela; - **garantido pelo banco**: PK, UNIQUE, índices únicos parciais (as unicidades "uma ativa por vez"), CHECK, EXCLUDE, triggers e colunas geradas, exatamente como estão no schema migrado; - **fora do banco**: regras do modelo que o banco não impõe e que cabem à aplicação, ou que ainda não são garantidas. Nenhuma FK é `DEFERRABLE` e nenhuma tem ação em cascata (`ON DELETE`/`ON UPDATE` são `NO ACTION`). FKs compostas com colunas anuláveis usam `MATCH SIMPLE`: a verificação é pulada enquanto alguma coluna da FK for nula. ## Todas as tabelas [#todas-as-tabelas] {/* gen-index */} | Tabela | Domínio | Para quê | | --- | --- | --- | | [`parties`](/modelo-de-dados/parties#parties) | Parties | Raiz do cadastro: uma pessoa ou empresa, com nome legal e documento. | | [`person_profiles`](/modelo-de-dados/parties#person_profiles) | Parties | Dados de pessoa física de uma party (1:1, a PK é a própria `party_id`). | | [`company_profiles`](/modelo-de-dados/parties#company_profiles) | Parties | Dados de pessoa jurídica de uma party (1:1, a PK é a própria `party_id`). | | [`party_addresses`](/modelo-de-dados/parties#party_addresses) | Parties | Endereços de uma party; vários por party, distinguidos por `kind`. | | [`party_contacts`](/modelo-de-dados/parties#party_contacts) | Parties | Contatos (e-mail, telefone…) de uma party, opcionalmente no contexto de uma membership, com finalidade (`purpose`). | | [`company_ownerships`](/modelo-de-dados/parties#company_ownerships) | Parties | Participação societária: quem é sócio de uma empresa, com percentual e período. | | [`credentials`](/modelo-de-dados/credentials#credentials) | Credentials | A credencial em si: método (`method`), identificador externo e ciclo de vida (expiração, último uso, revogação). | | [`credential_versions`](/modelo-de-dados/credentials#credential_versions) | Credentials | Cada versão do segredo: hash ou texto cifrado, quem criou e por quê, e o estado da versão. | | [`tenants`](/modelo-de-dados/tenancy#tenants) | Tenancy | O tenant: identificador público (`uid`), `slug`, nome da operação e o blueprint de origem. | | [`tenant_accounts`](/modelo-de-dados/tenancy#tenant_accounts) | Tenancy | Um CNPJ do grupo dentro do tenant (party de empresa), com rótulo opcional. | | [`users`](/modelo-de-dados/identity#users) | Identity | Identidade global de uma pessoa; no máximo um usuário por party. | | [`user_logins`](/modelo-de-dados/identity#user_logins) | Identity | Identificadores de login no namespace de um tenant (CPF, CNPJ, e-mail, código, username). | | [`platform_logins`](/modelo-de-dados/identity#platform_logins) | Identity | Identificadores de login do operador de plataforma (username ou e-mail), sem tenant; únicos globalmente enquanto ativos. | | [`user_credentials`](/modelo-de-dados/identity#user_credentials) | Identity | Liga um usuário às suas credenciais de login; uma credencial pertence a um único usuário. | | [`sessions`](/modelo-de-dados/identity#sessions) | Identity | Sessão autenticada: usuário, login e credencial usados, dados do cliente, expiração e revogação. | | [`refresh_tokens`](/modelo-de-dados/identity#refresh_tokens) | Identity | Tokens de renovação de uma sessão: só o SHA-256 é guardado, com uso único e encadeamento (`replaced_by`). | | [`financial_institutions`](/modelo-de-dados/financial-institutions#financial_institutions) | Financial Institutions | Catálogo global de IFs, com código opcional e único. | | [`financial_institution_accounts`](/modelo-de-dados/financial-institutions#financial_institution_accounts) | Financial Institutions | Vínculo de um CNPJ do tenant com uma IF; único por par (CNPJ, IF). | | [`financial_institution_credentials`](/modelo-de-dados/financial-institutions#financial_institution_credentials) | Financial Institutions | Credencial técnica de um vínculo CNPJ × IF, por ambiente, no mesmo tenant do vínculo. | | [`product_groups`](/modelo-de-dados/product-catalog#product_groups) | Product Catalog | Agrupamento opcional de produtos. | | [`products`](/modelo-de-dados/product-catalog#products) | Product Catalog | Produto do catálogo global, com grupo opcional. | | [`operation_types`](/modelo-de-dados/product-catalog#operation_types) | Product Catalog | Tipos de operação, catálogo independente com código único. | | [`financial_institution_products`](/modelo-de-dados/product-catalog#financial_institution_products) | Product Catalog | Oferta de um produto por uma IF; única por (IF, produto). | | [`financial_institution_account_products`](/modelo-de-dados/product-catalog#financial_institution_account_products) | Product Catalog | Habilitação de uma oferta para um vínculo CNPJ × IF; única por (vínculo, oferta). | | [`brokers`](/modelo-de-dados/brokers#brokers) | Brokers | Identidade global do corretor (party), com identificador público. | | [`tenant_brokers`](/modelo-de-dados/brokers#tenant_brokers) | Brokers | O corretor dentro de um tenant, com código no tenant e desativação. | | [`broker_affiliations`](/modelo-de-dados/brokers#broker_affiliations) | Brokers | Histórico da hierarquia entre corretores do mesmo tenant (corretor e corretor-pai), com início e fim; uma afiliação aberta por corretor. | | [`tenant_broker_accounts`](/modelo-de-dados/brokers#tenant_broker_accounts) | Brokers | Com quais CNPJs do grupo o corretor tem termo assinado (N:N), com vigência. | | [`store_codes`](/modelo-de-dados/brokers#store_codes) | Brokers | Registro dos códigos de loja e de seu dono: a conta do grupo na IF ou o corretor. | | [`broker_store_codes`](/modelo-de-dados/brokers#broker_store_codes) | Brokers | Qual código de loja o corretor usa em cada IF e em qual regime, com vigência `[valid_from, valid_until)` sem sobreposição. | | [`permissions`](/modelo-de-dados/authorization#permissions) | Authorization | Projeção do registry de permissões gerado pelos `@Requires` dos use cases: código `..`, escopo e descrição. | | [`permission_use_cases`](/modelo-de-dados/authorization#permission_use_cases) | Authorization | Quais use cases exigem cada permissão; sincronizado no deploy. | | [`permission_changes`](/modelo-de-dados/authorization#permission_changes) | Authorization | Auditoria prevista de mudanças no catálogo de permissões (commit, autor, aprovador, quem fez o deploy). | | [`access_profiles`](/modelo-de-dados/authorization#access_profiles) | Authorization | Perfil de acesso do tenant (`broker_id` nulo) ou de um corretor. | | [`access_profile_permissions`](/modelo-de-dados/authorization#access_profile_permissions) | Authorization | Permissões de um perfil. | | [`access_profile_templates`](/modelo-de-dados/authorization#access_profile_templates) | Authorization | Template de perfil do tenant, versionado: perfis ligados herdam as permissões ao vivo. | | [`access_profile_template_permissions`](/modelo-de-dados/authorization#access_profile_template_permissions) | Authorization | Permissões da versão atual de um template de tenant. | | [`access_profile_template_changes`](/modelo-de-dados/authorization#access_profile_template_changes) | Authorization | Cada edição confirmada de um template: versões de origem e destino, permissões adicionadas e removidas, motivo e autor. | | [`access_profile_template_change_decisions`](/modelo-de-dados/authorization#access_profile_template_change_decisions) | Authorization | Para cada perfil afetado por uma edição de template: acompanhou (`followed`) ou desvinculou (`detached`), e quantos usuários foram afetados. | | [`platform_tenant_blueprints`](/modelo-de-dados/authorization#platform_tenant_blueprints) | Authorization | Formato de tenant mantido pela plataforma; criar um tenant é escolher um blueprint. | | [`platform_access_profile_templates`](/modelo-de-dados/authorization#platform_access_profile_templates) | Authorization | Templates de perfil de um blueprint, copiados para `access_profile_templates` quando o tenant é criado. | | [`platform_access_profile_template_permissions`](/modelo-de-dados/authorization#platform_access_profile_template_permissions) | Authorization | Permissões de um template de plataforma. | | [`platform_access_profiles`](/modelo-de-dados/authorization#platform_access_profiles) | Authorization | Perfis acima dos tenants (por exemplo, suporte ou administração da plataforma). | | [`platform_access_profile_permissions`](/modelo-de-dados/authorization#platform_access_profile_permissions) | Authorization | Permissões de um perfil de plataforma; só permissões de escopo `platform`. | | [`platform_grants`](/modelo-de-dados/authorization#platform_grants) | Authorization | Operador de plataforma: usuário, perfil de plataforma, quem concedeu e por quê. | | [`platform_invitations`](/modelo-de-dados/authorization#platform_invitations) | Authorization | Convite para operar a plataforma. | | [`platform_invitation_deliveries`](/modelo-de-dados/authorization#platform_invitation_deliveries) | Authorization | Cada tentativa de envio de um convite de plataforma; reenvio é uma linha nova. | | [`platform_invitation_delivery_events`](/modelo-de-dados/authorization#platform_invitation_delivery_events) | Authorization | Linha do tempo bruta do provedor de envio; um webhook repetido não duplica (`dedup_key`). | | [`session_contexts`](/modelo-de-dados/authorization#session_contexts) | Authorization | Operador de plataforma atuando dentro de um tenant, com motivo. | | [`memberships`](/modelo-de-dados/workforce#memberships) | Workforce | Pessoa × tenant: usuário, party e quem concedeu ou removeu. | | [`membership_access_profiles`](/modelo-de-dados/workforce#membership_access_profiles) | Workforce | Perfil do tenant concedido à membership para o tenant inteiro, com vigência. | | [`membership_brokers`](/modelo-de-dados/workforce#membership_brokers) | Workforce | Atuar como usuário de um corretor, com um perfil daquele corretor. | | [`teams`](/modelo-de-dados/workforce#teams) | Workforce | Time do tenant (agrupamento comercial ou organizacional). | | [`membership_teams`](/modelo-de-dados/workforce#membership_teams) | Workforce | Pessoa no time, com um perfil do tenant e vigência `[valid_from, valid_until)` sem sobreposição para o mesmo par (time, membership). | | [`membership_portfolios`](/modelo-de-dados/workforce#membership_portfolios) | Workforce | Carteira: um corretor atendido por uma pessoa num time, coberta por uma `membership_teams` da mesma pessoa, time e tenant. | | [`team_financial_institution_accounts`](/modelo-de-dados/workforce#team_financial_institution_accounts) | Workforce | Contextos que um time atende: time → vínculo CNPJ × IF existente, com vigência sem sobreposição para o mesmo par. | | [`team_hierarchies`](/modelo-de-dados/workforce#team_hierarchies) | Workforce | Aresta organizacional temporal entre duas participações em time (subordinado e superior), com motivo. | | [`portfolio_transfers`](/modelo-de-dados/workforce#portfolio_transfers) | Workforce | Cabeçalho imutável de uma transferência manual de carteiras, com ator, motivo e data efetiva; `uid` identifica a operação idempotente. | | [`portfolio_transfer_items`](/modelo-de-dados/workforce#portfolio_transfer_items) | Workforce | Cada corretor transferido: carteira de origem e de destino do mesmo corretor e tenant; uma carteira só aparece uma vez como origem e uma vez como destino. | | [`invitations`](/modelo-de-dados/workforce#invitations) | Workforce | Convite para um tenant: canal, destino, SHA-256 do token, estado e validade. | | [`invitation_grants`](/modelo-de-dados/workforce#invitation_grants) | Workforce | O que o aceite do convite vai criar: perfil do tenant, atuação em corretor, time ou carteira. | | [`invitation_session_contexts`](/modelo-de-dados/workforce#invitation_session_contexts) | Workforce | Existe só quando um operador de plataforma, dentro de um `session_context`, emite o convite. | | [`invitation_deliveries`](/modelo-de-dados/workforce#invitation_deliveries) | Workforce | Cada tentativa de envio de um convite de tenant; reenvio é uma linha nova; exatamente um solicitante. | | [`invitation_delivery_events`](/modelo-de-dados/workforce#invitation_delivery_events) | Workforce | Linha do tempo bruta do provedor de envio; webhook repetido não duplica. | | [`session_memberships`](/modelo-de-dados/workforce#session_memberships) | Workforce | Em qual tenant uma sessão está atuando; fonte do contexto de tenant da transação. | | [`session_membership_brokers`](/modelo-de-dados/workforce#session_membership_brokers) | Workforce | Existe só quando a sessão atua como usuário de um corretor; trocar de corretor abre uma nova `session_memberships`. | | [`broker_prospects`](/modelo-de-dados/onboarding#broker_prospects) | Onboarding | Prospect de corretor no tenant: documento, contato, quem captou e quem digitou, e o corretor resultante da conversão. | | [`user_banks`](/modelo-de-dados/bank-users#user_banks) | Bank Users | O login de uma pessoa numa IF, com sua credencial; um aberto por (usuário, IF). | | [`bank_user_assignments`](/modelo-de-dados/bank-users#bank_user_assignments) | Bank Users | Atribuição de um usuário bancário a um corretor do tenant, com vigência por dia `[valid_from, valid_until)`, o código de loja sob o qual foi criado e a atuação de origem (`origin_membership_broker_id`). | | [`bank_user_transfers`](/modelo-de-dados/bank-users#bank_user_transfers) | Bank Users | Registro da transferência de um usuário bancário entre corretores do mesmo tenant: atribuição de origem e de destino, data efetiva, ator e motivo. | | [`bank_credential_deliveries`](/modelo-de-dados/bank-users#bank_credential_deliveries) | Bank Users | Revelação da senha bancária no portal: uma por versão da credencial (a PK é `credential_version_id`), com quem viu e quando (`clock_timestamp()`). | {/* /gen-index */} # Onboarding (/modelo-de-dados/onboarding) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} Onboarding guarda o corretor antes de virar cliente: o pipeline do cadastro, não um funil de vendas. Fica em domínio próprio porque o prospect é criado por uma membership (Workforce), e Workforce depende de Brokers; colocá-lo em Brokers criaria um ciclo. ```mermaid erDiagram tenants ||--o{ broker_prospects : "pipeline" memberships ||--o{ broker_prospects : "captou / digitou" parties ||--o{ broker_prospects : "confirmado" tenant_brokers ||--o| broker_prospects : "convertido" ``` ## Tabelas (1) [#tabelas-1] | Tabela | Para quê | | --- | --- | | [`broker_prospects`](#broker_prospects) | Prospect de corretor no tenant: documento, contato, quem captou e quem digitou, e o corretor resultante da conversão. | ## Referência [#referência] ### `broker_prospects` [#broker_prospects] Prospect de corretor no tenant: documento, contato, quem captou e quem digitou, e o corretor resultante da conversão. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `tenant_id` | `bigint` | não | | | `document_type` | `text` | não | | | `document_number` | `text` | não | | | `party_id` | `bigint` | sim | ligado quando a Receita confirma | | `legal_name` | `text` | sim | | | `contact_name` | `text` | não | | | `contact_email` | `text` | sim | | | `contact_phone` | `text` | sim | | | `stage` | `text` | não | hoje só intent | | `owner_membership_id` | `bigint` | não | comercial que captou | | `created_by_membership_id` | `bigint` | não | quem digitou | | `broker_id` | `bigint` | sim | preenchido na conversão | | `closed_reason` | `text` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(created_by_membership_id, tenant_id)` → `memberships (id, tenant_id)` (mesmo tenant) - `(owner_membership_id, tenant_id)` → `memberships (id, tenant_id)` (mesmo tenant) - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `party_id` → `parties.id` - `tenant_id` → `tenants.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `broker_prospects_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `broker_prospects_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - `stage` não tem CHECK de valores. - Fechamento (`closed_reason`) e conversão (`broker_id`) coerentes entre si são regra da aplicação. # Parties (/modelo-de-dados/parties) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} Parties guarda quem existe no mundo real, sem saber de tenant: uma pessoa física ou jurídica, seus perfis, endereços, contatos e participações societárias. Usuários, corretores, contas do grupo e prospects apontam para uma party, então um mesmo documento é cadastrado uma vez só. É o primeiro domínio da ordem de dependência: não referencia nenhum outro. A única ligação para fora, `party_contacts → memberships`, é criada pelo domínio Workforce, dono de `memberships`. ```mermaid erDiagram parties ||--o| person_profiles : "pessoa" parties ||--o| company_profiles : "empresa" parties ||--o{ party_addresses : "endereços" parties ||--o{ party_contacts : "contatos" company_profiles ||--o{ company_ownerships : "sócios" parties ||--o{ company_ownerships : "participa de" ``` ## Tabelas (6) [#tabelas-6] | Tabela | Para quê | | --- | --- | | [`parties`](#parties) | Raiz do cadastro: uma pessoa ou empresa, com nome legal e documento. | | [`person_profiles`](#person_profiles) | Dados de pessoa física de uma party (1:1, a PK é a própria `party_id`). | | [`company_profiles`](#company_profiles) | Dados de pessoa jurídica de uma party (1:1, a PK é a própria `party_id`). | | [`party_addresses`](#party_addresses) | Endereços de uma party; vários por party, distinguidos por `kind`. | | [`party_contacts`](#party_contacts) | Contatos (e-mail, telefone…) de uma party, opcionalmente no contexto de uma membership, com finalidade (`purpose`). | | [`company_ownerships`](#company_ownerships) | Participação societária: quem é sócio de uma empresa, com percentual e período. | ## Referência [#referência] ### `parties` [#parties] Raiz do cadastro: uma pessoa ou empresa, com nome legal e documento. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `kind` | `text` | não | | | `legal_name` | `text` | não | | | `document_type` | `text` | sim | | | `document_number` | `text` | sim | | | `document_country` | `text` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma Referenciada por: `broker_prospects`, `brokers`, `company_ownerships`, `company_profiles`, `party_addresses`, `party_contacts`, `person_profiles`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `parties_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `parties_document_key` | `UNIQUE (document_country, document_type, document_number)` | **Fora do banco** - `kind` não tem CHECK no banco; o tipo de party é validado na aplicação. - Coerência entre `kind` e o perfil (`person_profiles` ou `company_profiles`) é regra da aplicação. ### `person_profiles` [#person_profiles] Dados de pessoa física de uma party (1:1, a PK é a própria `party_id`). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `party_id` | `bigint` | não | | | `birth_date` | `date` | sim | | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `party_id` → `parties.id` Referenciada por: `users`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `person_profiles_pkey` | `PRIMARY KEY (party_id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `company_profiles` [#company_profiles] Dados de pessoa jurídica de uma party (1:1, a PK é a própria `party_id`). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `party_id` | `bigint` | não | | | `trade_name` | `text` | sim | | | `incorporated_on` | `date` | sim | | | `primary_activity_code` | `text` | sim | | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `party_id` → `parties.id` Referenciada por: `company_ownerships`, `tenant_accounts`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `company_profiles_pkey` | `PRIMARY KEY (party_id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `party_addresses` [#party_addresses] Endereços de uma party; vários por party, distinguidos por `kind`. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `party_id` | `bigint` | não | | | `kind` | `text` | não | | | `country_code` | `text` | não | | | `postal_code` | `text` | sim | | | `region` | `text` | sim | | | `city` | `text` | sim | | | `district` | `text` | sim | | | `street` | `text` | sim | | | `street_number` | `text` | sim | | | `complement` | `text` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `party_id` → `parties.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `party_addresses_pkey` | `PRIMARY KEY (id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `party_contacts` [#party_contacts] Contatos (e-mail, telefone…) de uma party, opcionalmente no contexto de uma membership, com finalidade (`purpose`). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `party_id` | `bigint` | não | | | `membership_id` | `bigint` | sim | | | `kind` | `text` | não | | | `label` | `text` | sim | | | `value` | `text` | não | | | `normalized_value` | `text` | não | | | `is_preferred` | `boolean` | não | padrão `false` | | `notifications_enabled` | `boolean` | não | padrão `false` | | `metadata` | `jsonb` | não | padrão `'{}'::jsonb` | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `purpose` | `text` | sim | administrativo \| financeiro \| comercial \| operacional \| jurídico | **Chaves estrangeiras** - `(membership_id, party_id)` → `memberships (id, party_id)` - `party_id` → `parties.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `party_contacts_purpose_check` | `CHECK ((purpose IN ('administrativo', 'financeiro', 'comercial', 'operacional', 'jurídico')))` | | PK | `party_contacts_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `party_contacts_value_key` | `UNIQUE (party_id, membership_id, kind, normalized_value)` | **Fora do banco** - Só um contato preferido por tipo não é garantido pelo banco. ### `company_ownerships` [#company_ownerships] Participação societária: quem é sócio de uma empresa, com percentual e período. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `company_party_id` | `bigint` | não | | | `owner_party_id` | `bigint` | não | | | `ownership_percent` | `numeric` | não | | | `started_on` | `date` | não | | | `ended_on` | `date` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `company_party_id` → `company_profiles.party_id` - `owner_party_id` → `parties.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `company_ownerships_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `company_ownerships_period_key` | `UNIQUE (company_party_id, owner_party_id, started_on)` | **Fora do banco** - Soma de percentuais e sobreposição de períodos não são verificadas pelo banco; `ended_on` também não tem CHECK de ordem. # Product Catalog (/modelo-de-dados/product-catalog) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} O catálogo é global: grupos de produto (opcionais), produtos e tipos de operação. Cada IF oferta produtos (`financial_institution_products`), e cada vínculo CNPJ × IF habilita algumas dessas ofertas (`financial_institution_account_products`). As FKs compostas garantem que a oferta habilitada é da mesma IF do vínculo e que o tenant é o do vínculo. O seeder de referência carrega o catálogo inicial a cada deploy (ver [Seeders](/migrations/seeders)). ```mermaid erDiagram product_groups ||--o{ products : "agrupa" financial_institutions ||--o{ financial_institution_products : "oferta" products ||--o{ financial_institution_products : "ofertado" financial_institution_accounts ||--o{ financial_institution_account_products : "habilita" financial_institution_products ||--o{ financial_institution_account_products : "habilitada" ``` ## Tabelas (5) [#tabelas-5] | Tabela | Para quê | | --- | --- | | [`product_groups`](#product_groups) | Agrupamento opcional de produtos. | | [`products`](#products) | Produto do catálogo global, com grupo opcional. | | [`operation_types`](#operation_types) | Tipos de operação, catálogo independente com código único. | | [`financial_institution_products`](#financial_institution_products) | Oferta de um produto por uma IF; única por (IF, produto). | | [`financial_institution_account_products`](#financial_institution_account_products) | Habilitação de uma oferta para um vínculo CNPJ × IF; única por (vínculo, oferta). | ## Referência [#referência] ### `product_groups` [#product_groups] Agrupamento opcional de produtos. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `name` | `text` | não | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma Referenciada por: `products`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `product_groups_pkey` | `PRIMARY KEY (id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `products` [#products] Produto do catálogo global, com grupo opcional. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `product_group_id` | `bigint` | sim | | | `name` | `text` | não | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `product_group_id` → `product_groups.id` Referenciada por: `financial_institution_products`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `products_pkey` | `PRIMARY KEY (id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `operation_types` [#operation_types] Tipos de operação, catálogo independente com código único. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `code` | `text` | não | | | `name` | `text` | não | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - nenhuma **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `operation_types_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `operation_types_code_unique` | `UNIQUE (code)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `financial_institution_products` [#financial_institution_products] Oferta de um produto por uma IF; única por (IF, produto). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `financial_institution_id` | `bigint` | não | | | `product_id` | `bigint` | não | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `financial_institution_id` → `financial_institutions.id` - `product_id` → `products.id` Referenciada por: `financial_institution_account_products`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `financial_institution_products_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `financial_institution_products_id_institution_key` | `UNIQUE (id, financial_institution_id)` | | UNIQUE | `financial_institution_products_institution_product_key` | `UNIQUE (financial_institution_id, product_id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `financial_institution_account_products` [#financial_institution_account_products] Habilitação de uma oferta para um vínculo CNPJ × IF; única por (vínculo, oferta). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `financial_institution_account_id` | `bigint` | não | | | `financial_institution_id` | `bigint` | não | | | `financial_institution_product_id` | `bigint` | não | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(financial_institution_account_id, financial_institution_id, tenant_id)` → `financial_institution_accounts (id, financial_institution_id, tenant_id)` (mesmo tenant) - `(financial_institution_product_id, financial_institution_id)` → `financial_institution_products (id, financial_institution_id)` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `financial_institution_account_products_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `financial_institution_account_products_account_offer_key` | `UNIQUE (financial_institution_account_id, financial_institution_product_id)` | **Fora do banco** - A disponibilidade efetiva (catálogo ativo, vínculos ativos e permissões) é calculada na aplicação; não há trigger para isso. - `updated_at` não se atualiza sozinho: nenhum trigger o mantém. # Tenancy (/modelo-de-dados/tenancy) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} Um tenant é uma operação isolada (a Nova é um tenant; um master white-label seria outro). `tenant_accounts` liga o tenant aos CNPJs do seu grupo, que são parties de empresa. Quase toda tabela posterior carrega `tenant_id` e usa FKs compostas com ele para que as duas pontas de uma relação fiquem no mesmo tenant (ver [Garantias do banco](/garantias)). ```mermaid erDiagram tenants ||--o{ tenant_accounts : "CNPJs do grupo" company_profiles ||--o{ tenant_accounts : "é" platform_tenant_blueprints ||--o{ tenants : "criado a partir de" ``` ## Tabelas (2) [#tabelas-2] | Tabela | Para quê | | --- | --- | | [`tenants`](#tenants) | O tenant: identificador público (`uid`), `slug`, nome da operação e o blueprint de origem. | | [`tenant_accounts`](#tenant_accounts) | Um CNPJ do grupo dentro do tenant (party de empresa), com rótulo opcional. | ## Referência [#referência] ### `tenants` [#tenants] O tenant: identificador público (`uid`), `slug`, nome da operação e o blueprint de origem. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `slug` | `text` | não | | | `name` | `text` | não | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `created_from_blueprint_id` | `bigint` | sim | blueprint copiado na criação | **Chaves estrangeiras** - `created_from_blueprint_id` → `platform_tenant_blueprints.id` Referenciada por: `access_profile_templates`, `access_profiles`, `broker_prospects`, `idempotency_keys`, `invitations`, `memberships`, `outbox_messages`, `session_contexts`, `task_runs`, `teams`, `tenant_accounts`, `tenant_brokers`, `user_logins`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `tenants_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `tenants_slug_unique` | `UNIQUE (slug)` | | UNIQUE | `tenants_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - A razão social fica em `parties.legal_name`; `tenants.name` é só o nome da operação. ### `tenant_accounts` [#tenant_accounts] Um CNPJ do grupo dentro do tenant (party de empresa), com rótulo opcional. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `party_id` | `bigint` | não | | | `label` | `text` | sim | | | `disabled_at` | `timestamptz` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `party_id` → `company_profiles.party_id` - `tenant_id` → `tenants.id` Referenciada por: `financial_institution_accounts`, `teams`, `tenant_broker_accounts`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `tenant_accounts_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `tenant_accounts_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `tenant_accounts_tenant_party_key` | `UNIQUE (tenant_id, party_id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. # Workforce (/modelo-de-dados/workforce) {/* Reference sections generated from the migrated schema (pg_catalog) and the data model notes. */} `memberships` é só **pessoa × tenant**: uma membership ativa por usuário em cada tenant. Tudo que dá poder ou contexto é uma ligação com `membership_id`, com `tenant_id`, vigência e autor: - `membership_access_profiles`: perfil do tenant inteiro; - `membership_brokers`: atuar como usuário de um corretor, com um perfil daquele corretor; - `membership_teams`: participar de um time, com um perfil do tenant; - `membership_portfolios`: carteira de um comercial (corretor + pessoa + time). A mesma pessoa pode acumular várias ligações; a permissão efetiva é a união das ligações ativas. Tudo tem origem: uma membership nasce de um convite (`origin_invitation_id`), e as ligações vêm de um grant de convite ou de uma ação direta registrada. ```mermaid erDiagram invitations ||--o{ memberships : "origem" memberships ||--o{ membership_access_profiles : "perfil do tenant" memberships ||--o{ membership_brokers : "atua no corretor" memberships ||--o{ membership_teams : "no time" teams ||--o{ membership_teams : "membros" membership_teams ||--o{ membership_portfolios : "carteiras" invitations ||--o{ invitation_grants : "concede" ``` ## Tabelas (17) [#tabelas-17] | Tabela | Para quê | | --- | --- | | [`memberships`](#memberships) | Pessoa × tenant: usuário, party e quem concedeu ou removeu. | | [`membership_access_profiles`](#membership_access_profiles) | Perfil do tenant concedido à membership para o tenant inteiro, com vigência. | | [`membership_brokers`](#membership_brokers) | Atuar como usuário de um corretor, com um perfil daquele corretor. | | [`teams`](#teams) | Time do tenant (agrupamento comercial ou organizacional). | | [`membership_teams`](#membership_teams) | Pessoa no time, com um perfil do tenant e vigência `[valid_from, valid_until)` sem sobreposição para o mesmo par (time, membership). | | [`membership_portfolios`](#membership_portfolios) | Carteira: um corretor atendido por uma pessoa num time, coberta por uma `membership_teams` da mesma pessoa, time e tenant. | | [`team_financial_institution_accounts`](#team_financial_institution_accounts) | Contextos que um time atende: time → vínculo CNPJ × IF existente, com vigência sem sobreposição para o mesmo par. | | [`team_hierarchies`](#team_hierarchies) | Aresta organizacional temporal entre duas participações em time (subordinado e superior), com motivo. | | [`portfolio_transfers`](#portfolio_transfers) | Cabeçalho imutável de uma transferência manual de carteiras, com ator, motivo e data efetiva; `uid` identifica a operação idempotente. | | [`portfolio_transfer_items`](#portfolio_transfer_items) | Cada corretor transferido: carteira de origem e de destino do mesmo corretor e tenant; uma carteira só aparece uma vez como origem e uma vez como destino. | | [`invitations`](#invitations) | Convite para um tenant: canal, destino, SHA-256 do token, estado e validade. | | [`invitation_grants`](#invitation_grants) | O que o aceite do convite vai criar: perfil do tenant, atuação em corretor, time ou carteira. | | [`invitation_session_contexts`](#invitation_session_contexts) | Existe só quando um operador de plataforma, dentro de um `session_context`, emite o convite. | | [`invitation_deliveries`](#invitation_deliveries) | Cada tentativa de envio de um convite de tenant; reenvio é uma linha nova; exatamente um solicitante. | | [`invitation_delivery_events`](#invitation_delivery_events) | Linha do tempo bruta do provedor de envio; webhook repetido não duplica. | | [`session_memberships`](#session_memberships) | Em qual tenant uma sessão está atuando; fonte do contexto de tenant da transação. | | [`session_membership_brokers`](#session_membership_brokers) | Existe só quando a sessão atua como usuário de um corretor; trocar de corretor abre uma nova `session_memberships`. | ## Referência [#referência] ### `memberships` [#memberships] Pessoa × tenant: usuário, party e quem concedeu ou removeu. Uma ativa por (tenant, usuário); quem foi removido pode voltar. Nasce sempre de um convite. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `tenant_id` | `bigint` | não | | | `user_id` | `bigint` | não | | | `party_id` | `bigint` | não | | | `granted_by` | `bigint` | sim | | | `granted_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `removed_by` | `bigint` | sim | | | `removed_at` | `timestamptz` | sim | | | `origin_invitation_id` | `bigint` | não | todo vínculo nasce de um convite | **Chaves estrangeiras** - `(origin_invitation_id, tenant_id)` → `invitations (id, tenant_id)` (mesmo tenant) - `(user_id, party_id)` → `users (id, party_id)` - `granted_by` → `users.id` - `removed_by` → `users.id` - `tenant_id` → `tenants.id` - `user_id` → `users.id` Referenciada por: `bank_user_transfers`, `broker_prospects`, `membership_access_profiles`, `membership_brokers`, `membership_portfolios`, `membership_teams`, `party_contacts`, `portfolio_transfers`, `session_memberships`, `task_run_user_actors`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `memberships_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `memberships_id_party_key` | `UNIQUE (id, party_id)` | | UNIQUE | `memberships_id_party_tenant_key` | `UNIQUE (id, party_id, tenant_id)` | | UNIQUE | `memberships_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `memberships_id_user_tenant_key` | `UNIQUE (id, user_id, tenant_id)` | | UNIQUE | `memberships_uid_unique` | `UNIQUE (uid)` | | UNIQUE parcial | `memberships_live_key` | `(tenant_id, user_id) WHERE (removed_at IS NULL)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `membership_access_profiles` [#membership_access_profiles] Perfil do tenant concedido à membership para o tenant inteiro, com vigência. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `membership_id` | `bigint` | não | | | `access_profile_id` | `bigint` | não | perfil do tenant (broker_id nulo) | | `access_profile_scope` | `text` | não | CHECK = 'tenant': só perfil do tenant | | `valid_from` | `date` | não | | | `valid_until` | `date` | sim | | | `granted_by_user_id` | `bigint` | sim | ação direta | | `origin_invitation_grant_id` | `bigint` | sim | ou: veio de convite | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(access_profile_id, tenant_id, access_profile_scope)` → `access_profiles (id, tenant_id, scope)` (mesmo tenant) - `(membership_id, tenant_id)` → `memberships (id, tenant_id)` (mesmo tenant) - `granted_by_user_id` → `users.id` - `origin_invitation_grant_id` → `invitation_grants.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `membership_access_profiles_scope_check` | `CHECK ((access_profile_scope = 'tenant'))` | | PK | `membership_access_profiles_pkey` | `PRIMARY KEY (id)` | | UNIQUE parcial | `membership_access_profiles_live_key` | `(membership_id, access_profile_id) WHERE (valid_until IS NULL)` | **Fora do banco** - Não há CHECK de ordem do período nem EXCLUDE de sobreposição; só a unicidade da ligação aberta é garantida. - Ter ao menos uma origem (`granted_by_user_id` ou `origin_invitation_grant_id`) não tem CHECK. ### `membership_brokers` [#membership_brokers] Atuar como usuário de um corretor, com um perfil daquele corretor. É o vínculo sob o qual a ação do usuário de corretor é auditada. `user_id` é cópia de `memberships.user_id`, presa por FK. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `membership_id` | `bigint` | não | | | `user_id` | `bigint` | não | cópia de memberships.user_id, presa por FK | | `broker_id` | `bigint` | não | | | `access_profile_id` | `bigint` | não | perfil daquele corretor | | `valid_from` | `date` | não | | | `valid_until` | `date` | sim | | | `granted_by_user_id` | `bigint` | sim | ação direta | | `origin_invitation_grant_id` | `bigint` | sim | ou: veio de convite | | `removed_by_user_id` | `bigint` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(access_profile_id, tenant_id, broker_id)` → `access_profiles (id, tenant_id, broker_id)` (mesmo tenant) - `(membership_id, tenant_id)` → `memberships (id, tenant_id)` (mesmo tenant) - `(membership_id, user_id, tenant_id)` → `memberships (id, user_id, tenant_id)` (mesmo tenant) - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `granted_by_user_id` → `users.id` - `origin_invitation_grant_id` → `invitation_grants.id` - `removed_by_user_id` → `users.id` Referenciada por: `bank_user_assignments`, `session_membership_brokers`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `membership_brokers_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `membership_brokers_id_tenant_broker_user_key` | `UNIQUE (id, tenant_id, broker_id, user_id)` | | UNIQUE parcial | `membership_brokers_live_key` | `(membership_id, broker_id) WHERE (valid_until IS NULL)` | **Fora do banco** - Não há CHECK de ordem do período nem EXCLUDE de sobreposição; só uma ligação aberta por (membership, corretor) é garantida. - Ter ao menos uma origem não tem CHECK. ### `teams` [#teams] Time do tenant (agrupamento comercial ou organizacional). O CNPJ de origem é opcional e não concede acesso nem isola dados. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `tenant_id` | `bigint` | não | | | `tenant_account_id` | `bigint` | sim | CNPJ de origem opcional; não restringe sozinho os contextos atendidos nem concede acesso | | `name` | `text` | não | | | `description` | `text` | sim | | | `disabled_at` | `timestamptz` | sim | | | `created_by_user_id` | `bigint` | não | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `updated_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(tenant_account_id, tenant_id)` → `tenant_accounts (id, tenant_id)` (mesmo tenant) - `created_by_user_id` → `users.id` - `tenant_id` → `tenants.id` Referenciada por: `invitation_grants`, `membership_teams`, `team_financial_institution_accounts`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `teams_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `teams_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `teams_tenant_name_key` | `UNIQUE (tenant_id, name)` | | UNIQUE | `teams_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `membership_teams` [#membership_teams] Pessoa no time, com um perfil do tenant e vigência `[valid_from, valid_until)` sem sobreposição para o mesmo par (time, membership). | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `team_id` | `bigint` | não | | | `membership_id` | `bigint` | não | pessoa no tenant; não confundir com membership_brokers | | `access_profile_id` | `bigint` | não | o que a pessoa pode fazer no time | | `access_profile_scope` | `text` | não | CHECK = 'tenant': só perfil do tenant | | `valid_from` | `date` | não | | | `valid_until` | `date` | sim | | | `added_by_user_id` | `bigint` | sim | ação direta | | `origin_invitation_grant_id` | `bigint` | sim | ou: veio de convite | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(access_profile_id, tenant_id, access_profile_scope)` → `access_profiles (id, tenant_id, scope)` (mesmo tenant) - `(membership_id, tenant_id)` → `memberships (id, tenant_id)` (mesmo tenant) - `(team_id, tenant_id)` → `teams (id, tenant_id)` (mesmo tenant) - `added_by_user_id` → `users.id` - `origin_invitation_grant_id` → `invitation_grants.id` Referenciada por: `membership_portfolios`, `team_hierarchies`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `membership_team_period` | `CHECK (((valid_until IS NULL) OR (valid_until > valid_from)))` | | CHECK | `membership_teams_scope_check` | `CHECK ((access_profile_scope = 'tenant'))` | | PK | `membership_teams_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `membership_teams_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `membership_teams_id_tenant_membership_team_key` | `UNIQUE (id, tenant_id, membership_id, team_id)` | | EXCLUDE | `membership_team_no_overlap` | `EXCLUDE USING gist (team_id WITH =, membership_id WITH =, daterange(valid_from, valid_until, '[)') WITH &&)` | | UNIQUE parcial | `membership_teams_live_key` | `(team_id, membership_id) WHERE (valid_until IS NULL)` | **Fora do banco** - Encerrar a participação exige tratar na mesma transação as carteiras e arestas de hierarquia que dependem dela. ### `membership_portfolios` [#membership_portfolios] Carteira: um corretor atendido por uma pessoa num time, coberta por uma `membership_teams` da mesma pessoa, time e tenant. Um corretor tem no máximo uma carteira aberta por tenant. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `membership_id` | `bigint` | não | | | `team_id` | `bigint` | não | | | `membership_team_id` | `bigint` | não | | | `broker_id` | `bigint` | não | | | `valid_from` | `date` | não | | | `valid_until` | `date` | sim | | | `assigned_by_user_id` | `bigint` | sim | ação direta | | `origin_invitation_grant_id` | `bigint` | sim | ou: veio de convite | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(membership_id, tenant_id)` → `memberships (id, tenant_id)` (mesmo tenant) - `(membership_team_id, tenant_id, membership_id, team_id)` → `membership_teams (id, tenant_id, membership_id, team_id)` (mesmo tenant) - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) - `assigned_by_user_id` → `users.id` - `origin_invitation_grant_id` → `invitation_grants.id` Referenciada por: `portfolio_transfer_items`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `membership_portfolio_period` | `CHECK (((valid_until IS NULL) OR (valid_until > valid_from)))` | | PK | `membership_portfolios_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `membership_portfolios_id_tenant_broker_key` | `UNIQUE (id, tenant_id, broker_id)` | | UNIQUE parcial | `membership_portfolios_live_key` | `(tenant_id, broker_id) WHERE (valid_until IS NULL)` | **Fora do banco** - A vigência da carteira caber na vigência de `membership_teams` não é verificada pelo banco. - Identidades e `valid_from` imutáveis após a atribuição são regra da aplicação. ### `team_financial_institution_accounts` [#team_financial_institution_accounts] Contextos que um time atende: time → vínculo CNPJ × IF existente, com vigência sem sobreposição para o mesmo par. Não é propriedade da conta nem concede leitura da produção de outros corretores. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `team_id` | `bigint` | não | | | `financial_institution_account_id` | `bigint` | não | | | `valid_from` | `date` | não | | | `valid_until` | `date` | sim | exclusivo; nulo = sem fim definido | | `created_by_user_id` | `bigint` | não | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(financial_institution_account_id, tenant_id)` → `financial_institution_accounts (id, tenant_id)` (mesmo tenant) - `(team_id, tenant_id)` → `teams (id, tenant_id)` (mesmo tenant) - `created_by_user_id` → `users.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `team_fi_service_period` | `CHECK (((valid_until IS NULL) OR (valid_until > valid_from)))` | | PK | `team_financial_institution_accounts_pkey` | `PRIMARY KEY (id)` | | EXCLUDE | `team_fi_service_no_overlap` | `EXCLUDE USING gist (team_id WITH =, financial_institution_account_id WITH =, daterange(valid_from, valid_until, '[)') WITH &&)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `team_hierarchies` [#team_hierarchies] Aresta organizacional temporal entre duas participações em time (subordinado e superior), com motivo. A hierarquia é configurável e não concede permissão. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `subordinate_membership_team_id` | `bigint` | não | | | `superior_membership_team_id` | `bigint` | não | | | `valid_from` | `date` | não | | | `valid_until` | `date` | sim | exclusivo; nulo = sem fim definido | | `recorded_by_user_id` | `bigint` | não | | | `reason` | `text` | não | | | `recorded_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(subordinate_membership_team_id, tenant_id)` → `membership_teams (id, tenant_id)` (mesmo tenant) - `(superior_membership_team_id, tenant_id)` → `membership_teams (id, tenant_id)` (mesmo tenant) - `recorded_by_user_id` → `users.id` **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `team_hierarchy_distinct_endpoints` | `CHECK ((subordinate_membership_team_id <> superior_membership_team_id))` | | CHECK | `team_hierarchy_period` | `CHECK (((valid_until IS NULL) OR (valid_until > valid_from)))` | | CHECK | `team_hierarchy_reason` | `CHECK ((length(btrim(reason)) > 0))` | | PK | `team_hierarchies_pkey` | `PRIMARY KEY (id)` | | EXCLUDE | `team_hierarchy_edge_no_overlap` | `EXCLUDE USING gist (subordinate_membership_team_id WITH =, superior_membership_team_id WITH =, daterange(valid_from, valid_until, '[)') WITH &&)` | **Fora do banco** - Ausência de ciclos, cobertura da vigência pelas duas `membership_teams` e inserções concorrentes ainda não são garantidas: exigem constraint trigger ou protocolo transacional. O EXCLUDE só impede a mesma aresta sobreposta no tempo. ### `portfolio_transfers` [#portfolio_transfers] Cabeçalho imutável de uma transferência manual de carteiras, com ator, motivo e data efetiva; `uid` identifica a operação idempotente. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `tenant_id` | `bigint` | não | | | `actor_user_id` | `bigint` | não | | | `actor_membership_id` | `bigint` | não | | | `reason` | `text` | não | | | `effective_on` | `date` | não | | | `recorded_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(actor_membership_id, actor_user_id, tenant_id)` → `memberships (id, user_id, tenant_id)` (mesmo tenant) Referenciada por: `portfolio_transfer_items`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `portfolio_transfer_reason` | `CHECK ((length(btrim(reason)) > 0))` | | PK | `portfolio_transfers_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `portfolio_transfers_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `portfolio_transfers_tenant_uid_key` | `UNIQUE (tenant_id, uid)` | **Fora do banco** - Conter ao menos um item e gravar cabeçalho, itens, fechamento das origens e abertura dos destinos numa única transação são regras da aplicação. ### `portfolio_transfer_items` [#portfolio_transfer_items] Cada corretor transferido: carteira de origem e de destino do mesmo corretor e tenant; uma carteira só aparece uma vez como origem e uma vez como destino. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `portfolio_transfer_id` | `bigint` | não | | | `broker_id` | `bigint` | não | | | `from_membership_portfolio_id` | `bigint` | não | | | `to_membership_portfolio_id` | `bigint` | não | | **Chaves estrangeiras** - `(from_membership_portfolio_id, tenant_id, broker_id)` → `membership_portfolios (id, tenant_id, broker_id)` (mesmo tenant) - `(portfolio_transfer_id, tenant_id)` → `portfolio_transfers (id, tenant_id)` (mesmo tenant) - `(to_membership_portfolio_id, tenant_id, broker_id)` → `membership_portfolios (id, tenant_id, broker_id)` (mesmo tenant) **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `portfolio_transfer_distinct_portfolios` | `CHECK ((from_membership_portfolio_id <> to_membership_portfolio_id))` | | PK | `portfolio_transfer_items_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `portfolio_transfer_items_from_membership_portfolio_id_unique` | `UNIQUE (from_membership_portfolio_id)` | | UNIQUE | `portfolio_transfer_items_to_membership_portfolio_id_unique` | `UNIQUE (to_membership_portfolio_id)` | | UNIQUE | `portfolio_transfer_items_transfer_broker_key` | `UNIQUE (portfolio_transfer_id, broker_id)` | **Fora do banco** - Destino com a mesma pessoa e o mesmo time da origem é recusado na aplicação. - A vigência do destino caber em `membership_teams` não é verificada pelo banco. ### `invitations` [#invitations] Convite para um tenant: canal, destino, SHA-256 do token, estado e validade. Não cria nada sozinho: conta e vínculos nascem no aceite. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `tenant_id` | `bigint` | não | | | `channel` | `text` | não | email \| phone | | `destination` | `text` | não | | | `destination_normalized` | `text` | não | | | `token_sha256` | `bytea` | não | o link leva o token; aqui só o hash | | `status` | `text` | não | pending \| accepted \| revoked \| expired | | `expires_at` | `timestamptz` | não | | | `created_by_user_id` | `bigint` | sim | ação humana; com tenant_id identifica a membership | | `created_by_process` | `text` | sim | broker_activation \| seed \| … | | `accepted_at` | `timestamptz` | sim | | | `accepted_by_user_id` | `bigint` | sim | | | `revoked_at` | `timestamptz` | sim | | | `revoked_by_user_id` | `bigint` | sim | | | `created_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `accepted_by_user_id` → `users.id` - `created_by_user_id` → `users.id` - `revoked_by_user_id` → `users.id` - `tenant_id` → `tenants.id` Referenciada por: `invitation_deliveries`, `invitation_grants`, `invitation_session_contexts`, `memberships`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `invitations_channel_check` | `CHECK ((channel IN ('email', 'phone')))` | | CHECK | `invitations_creator_check` | `CHECK ((num_nonnulls(created_by_user_id, created_by_process) = 1))` | | CHECK | `invitations_status_check` | `CHECK ((status IN ('pending', 'accepted', 'revoked', 'expired')))` | | PK | `invitations_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `invitations_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `invitations_token_sha256_unique` | `UNIQUE (token_sha256)` | | UNIQUE | `invitations_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - O teto de concessão é conferido na criação e no aceite, pela aplicação. ### `invitation_grants` [#invitation_grants] O que o aceite do convite vai criar: perfil do tenant, atuação em corretor, time ou carteira. `access_profile_scope` é gerada a partir de `kind`. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `invitation_id` | `bigint` | não | | | `kind` | `text` | não | access_profile \| broker \| team \| portfolio | | `access_profile_id` | `bigint` | sim | | | `access_profile_scope` | `text` | sim | gerada: `CASE kind WHEN 'broker' THEN 'broker' WHEN 'access_profile' THEN 'tenant' WHEN 'team' THEN 'tenant' ELSE NULL::text END` | | `broker_id` | `bigint` | sim | | | `team_id` | `bigint` | sim | | **Chaves estrangeiras** - `(access_profile_id, tenant_id, access_profile_scope)` → `access_profiles (id, tenant_id, scope)` (mesmo tenant) - `(invitation_id, tenant_id)` → `invitations (id, tenant_id)` (mesmo tenant) - `(team_id, tenant_id)` → `teams (id, tenant_id)` (mesmo tenant) - `(tenant_id, broker_id)` → `tenant_brokers (tenant_id, broker_id)` (mesmo tenant) Referenciada por: `membership_access_profiles`, `membership_brokers`, `membership_portfolios`, `membership_teams`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `invitation_grants_kind_check` | `CHECK ((kind IN ('access_profile', 'broker', 'team', 'portfolio')))` | | CHECK | `invitation_grants_kind_columns_check` | `CHECK ( CASE kind WHEN 'access_profile' THEN ((access_profile_id IS NOT NULL) AND (broker_id IS NULL) AND (team_id IS NULL)) WHEN 'broker' THEN ((access_profile_id IS NOT NULL) AND (broker_id IS NOT NULL) AND (team_id IS NULL)) WHEN 'team' THEN ((access_profile_id IS NOT NULL) AND (broker_id IS NULL) AND (team_id IS NOT NULL)) WHEN 'portfolio' THEN ((access_profile_id IS NULL) AND (broker_id IS NOT NULL) AND (team_id IS NOT NULL)) ELSE false END)` | | PK | `invitation_grants_pkey` | `PRIMARY KEY (id)` | | Coluna gerada | `access_profile_scope` | `STORED` | **Fora do banco** - Para `portfolio`, o aceite resolve a `membership_teams` da pessoa no time; ausência, ambiguidade ou vigência incompatível recusam a concessão inteira. ### `invitation_session_contexts` [#invitation_session_contexts] Existe só quando um operador de plataforma, dentro de um `session_context`, emite o convite. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `invitation_id` | `bigint` | não | | | `tenant_id` | `bigint` | não | | | `session_context_id` | `bigint` | não | operador de plataforma que emitiu o convite | **Chaves estrangeiras** - `(invitation_id, tenant_id)` → `invitations (id, tenant_id)` (mesmo tenant) - `(session_context_id, tenant_id)` → `session_contexts (id, tenant_id)` (mesmo tenant) **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `invitation_session_contexts_pkey` | `PRIMARY KEY (invitation_id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `invitation_deliveries` [#invitation_deliveries] Cada tentativa de envio de um convite de tenant; reenvio é uma linha nova; exatamente um solicitante. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `uid` | `text` | não | | | `invitation_id` | `bigint` | não | | | `tenant_id` | `bigint` | não | | | `channel` | `text` | não | email \| sms \| whatsapp | | `destination_normalized` | `text` | não | foto no envio | | `provider` | `text` | não | ses \| sendgrid \| twilio \| zenvia … | | `provider_message_id` | `text` | sim | | | `status` | `text` | não | queued \| sent \| delivered \| opened \| clicked \| bounced \| failed \| complained | | `queued_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `sent_at` | `timestamptz` | sim | | | `delivered_at` | `timestamptz` | sim | | | `opened_at` | `timestamptz` | sim | | | `failed_at` | `timestamptz` | sim | | | `failure_reason` | `text` | sim | | | `requested_by_user_id` | `bigint` | sim | | | `requested_by_process` | `text` | sim | | **Chaves estrangeiras** - `(invitation_id, tenant_id)` → `invitations (id, tenant_id)` (mesmo tenant) - `requested_by_user_id` → `users.id` Referenciada por: `invitation_delivery_events`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `invitation_deliveries_channel_check` | `CHECK ((channel IN ('email', 'sms', 'whatsapp')))` | | CHECK | `invitation_deliveries_requester_check` | `CHECK ((num_nonnulls(requested_by_user_id, requested_by_process) = 1))` | | CHECK | `invitation_deliveries_status_check` | `CHECK ((status IN ('queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'failed', 'complained')))` | | PK | `invitation_deliveries_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `invitation_deliveries_id_tenant_key` | `UNIQUE (id, tenant_id)` | | UNIQUE | `invitation_deliveries_provider_message_key` | `UNIQUE (provider, provider_message_id)` | | UNIQUE | `invitation_deliveries_uid_unique` | `UNIQUE (uid)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `invitation_delivery_events` [#invitation_delivery_events] Linha do tempo bruta do provedor de envio; webhook repetido não duplica. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `tenant_id` | `bigint` | não | | | `delivery_id` | `bigint` | não | | | `status` | `text` | não | | | `occurred_at` | `timestamptz` | não | horário do provedor, em UTC | | `dedup_key` | `text` | não | id do evento no provedor, ou sha256 do payload quando não houver | | `received_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `payload` | `jsonb` | não | padrão `'{}'::jsonb`; webhook como chegou, sem token | **Chaves estrangeiras** - `(delivery_id, tenant_id)` → `invitation_deliveries (id, tenant_id)` (mesmo tenant) **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | CHECK | `invitation_delivery_events_payload_size_check` | `CHECK ((pg_column_size(payload) < 64000))` | | PK | `invitation_delivery_events_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `invitation_delivery_events_dedup_key` | `UNIQUE (delivery_id, dedup_key)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. ### `session_memberships` [#session_memberships] Em qual tenant uma sessão está atuando; fonte do contexto de tenant da transação. Trocar de tenant fecha a linha. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `id` | `bigint` | não | identity (`GENERATED ALWAYS`) | | `session_id` | `bigint` | não | | | `tenant_id` | `bigint` | não | | | `membership_id` | `bigint` | não | | | `user_id` | `bigint` | não | prova que a sessão é da pessoa | | `selected_at` | `timestamptz` | não | padrão `clock_timestamp()` | | `left_at` | `timestamptz` | sim | troca de tenant fecha a linha | **Chaves estrangeiras** - `(membership_id, user_id, tenant_id)` → `memberships (id, user_id, tenant_id)` (mesmo tenant) - `(session_id, user_id)` → `sessions (id, user_id)` Referenciada por: `session_membership_brokers`. **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `session_memberships_pkey` | `PRIMARY KEY (id)` | | UNIQUE | `session_memberships_id_tenant_user_key` | `UNIQUE (id, tenant_id, user_id)` | | UNIQUE parcial | `session_memberships_live_key` | `(session_id) WHERE (left_at IS NULL)` | **Fora do banco** - Sem sessões criadas hoje (autenticação em reconstrução). ### `session_membership_brokers` [#session_membership_brokers] Existe só quando a sessão atua como usuário de um corretor; trocar de corretor abre uma nova `session_memberships`. | Coluna | Tipo | Nulo | Observação | | --- | --- | --- | --- | | `session_membership_id` | `bigint` | não | | | `tenant_id` | `bigint` | não | | | `user_id` | `bigint` | não | | | `broker_id` | `bigint` | não | | | `membership_broker_id` | `bigint` | não | corretor escolhido no login | | `selected_at` | `timestamptz` | não | padrão `clock_timestamp()` | **Chaves estrangeiras** - `(membership_broker_id, tenant_id, broker_id, user_id)` → `membership_brokers (id, tenant_id, broker_id, user_id)` (mesmo tenant) - `(session_membership_id, tenant_id, user_id)` → `session_memberships (id, tenant_id, user_id)` (mesmo tenant) **Garantido pelo banco** | Tipo | Nome | Definição | | --- | --- | --- | | PK | `session_membership_brokers_pkey` | `PRIMARY KEY (session_membership_id)` | **Fora do banco** - Nenhuma regra registrada além das constraints acima. # Backups (/operacao/backups) O PostgreSQL de staging tem backup diário no [Garage](https://garagehq.deuxfleurs.fr/) v2.4.1, um armazenamento compatível com S3 que roda como duas aplicações a mais no mesmo servidor do Dokploy (NOVA-119). A escolha do Garage em vez do MinIO: um binário pequeno, com modo de nó único documentado e uma API de administração para criar o bucket e a chave por script. A imagem dele não tem shell, por isso a configuração inicial é um job separado. ## As peças [#as-peças] | Peça | O que é | | --- | --- | | `garage` | `dxflrs/garage:v2.4.1`, um nó (`replication_factor = 1`), metadados em SQLite, dois volumes (metadados e dados), atualização `stop-first`: duas tarefas no mesmo volume de metadados o corromperiam | | `garage.toml` | Commitado sem segredos; o segredo de RPC e o token de administração vêm do ambiente do Dokploy, gerados uma vez pelo `apply` e nunca impressos | | API S3 (porta 3900) | A única parte pública: `https://s3.novahml.com` (Let's Encrypt), path-style, região `garage` | | API de administração (porta 3903) | Sem domínio: alcançável só na rede interna do swarm | | `garage-init` | Job one-shot (`curlimages/curl`): define o layout do nó quando ele não tem um, importa a chave de backup que o `apply` gerou, cria o bucket `monalisa-staging-backups` e dá à chave leitura e escrita **só nesse bucket** | | Destino `garage-staging` | Destino S3 do Dokploy, com essa chave | | Backup do PostgreSQL | `pg_dump -Fc` do banco, compactado, todo dia às **03:00 UTC** pelo agendador do Dokploy, mantendo as **14** cópias mais recentes | ## O que os backups cobrem [#o-que-os-backups-cobrem] Os backups ficam **só no servidor (VPS)** do banco. Eles protegem contra erros lógicos (uma migration ruim, um `DELETE` errado, uma versão quebrada) e permitem voltar staging a um dia anterior. **Não** protegem contra a perda do servidor ou do disco: não há cópia fora dele, por decisão, porque os dados de staging podem ser reconstruídos. O Valkey e o ElasticMQ não têm backup. O Valkey guarda só caches, portões de idempotência e sinais de despertar, e pode começar vazio; as filas do ElasticMQ são em memória. ## Num servidor novo [#num-servidor-novo] 1. `dokploy:apply` declara `garage`, `garage-init`, volumes, montagens, domínio e segredos, e avisa que os backups esperam o `garage-init`. 2. `dokploy:deploy` sobe o Garage e roda o `garage-init` depois da versão (ver [Staging no Dokploy](/operacao/staging-dokploy)), conferindo que ele termina `Complete`. 3. O `apply` seguinte cria o destino com a chave do `garage-init`, faz o Dokploy testar a conexão listando o bucket e agenda o backup. Bucket e chave só existem depois do `garage-init`, e um destino não testado só falharia às 03:00. O DNS do domínio S3 precisa apontar para o servidor antes do primeiro `apply` que o usa, para o Let's Encrypt emitir o certificado. Uma mudança em `garage.toml` só vale depois de republicar o `garage` na interface; uma mudança no script do `garage-init` roda com `dokploy:deploy --reinit`. Para trocar a chave de backup: remova as duas variáveis da chave do ambiente do `garage-init`, rode `apply` (gera um par novo), `deploy --reinit` (importa) e `apply` (atualiza o destino), e então apague a chave antiga no Garage, ou ao menos retire o acesso dela ao bucket; o Garage a mantém, com os direitos, até lá. ## Verificar: `dokploy:backup-check` [#verificar-dokploybackup-check] ```sh bun run dokploy:backup-check ``` O comando dispara o backup agendado na hora e confere que um arquivo `.sql.gz` novo e não vazio chegou ao bucket, imprimindo a chave e o tamanho dele. ## Restaurar [#restaurar] O Dokploy só oferece restauração pela interface (não há endpoint de API), então o teste é manual, num banco de rascunho: 1. Rode `dokploy:backup-check` e anote o arquivo que ele imprimiu. 2. No servidor, crie um banco de rascunho no contêiner do PostgreSQL (`createdb … monalisa_restore_check`). 3. Na interface do Dokploy (staging › postgres › Backups › Restore), escolha o destino `garage-staging`, o arquivo e o banco `monalisa_restore_check`. O Dokploy roda `pg_restore -O --clean --if-exists` nesse banco. 4. Compare com o banco original: - a migration mais nova (`select name from mikro_orm_migrations order by id desc limit 1`) deve ser igual ao `latestMigration` do `/ready`; - o número de tabelas (`select count(*) from information_schema.tables where table_schema = 'public'`) deve ser igual ao do banco `monalisa`. 5. Apague o banco de rascunho (`dropdb`). Restaurar sobre o próprio `monalisa` é a mesma ação na interface, com o nome `monalisa`: pare a API e os workers antes. Uma restauração foi testada em 2026-10-09: o banco restaurado tinha as 82 tabelas e as migrations até `0017_authorization-scope-ceiling-locks`. # Staging no Dokploy (/operacao/staging-dokploy) Staging roda no Dokploy como aplicações separadas, declaradas em `infra/dokploy/src/staging.config.ts` e convergidas por um script sobre a API do Dokploy. `compose.yaml` continua sendo a pilha local e a referência do que cada serviço precisa. | Serviço | Tipo no Dokploy | Origem | | --- | --- | --- | | `postgres` | Banco (Postgres 18) | `postgres:18.6-alpine`; senha gerada uma vez e mantida pelo Dokploy | | `redis` | Banco (tipo Redis, Valkey 8) | `valkey/valkey:8.1.10-alpine`; senha gerada uma vez e mantida pelo Dokploy | | `elasticmq` | Aplicação (imagem Docker) | `softwaremill/elasticmq-native:1.7.1`, com `elasticmq.conf` montado | | `migrator` | Aplicação (GitHub, alvo `migrator`) | One-shot: restart `none`, update `continue` | | `workers` | Aplicação (GitHub, alvo `workers`) | 45 s de graça na parada (orçamento de drenagem) | | `api` | Aplicação (GitHub, alvo `runtime`) | Domínio HTTPS (Let's Encrypt) na porta 3000, 30 s de graça na parada | | `garage` | Aplicação (imagem Docker) | `dxflrs/garage:v2.4.1`, `garage.toml` montado, dois volumes, domínio S3 ([Backups](/operacao/backups)) | | `garage-init` | Aplicação (imagem Docker) | `curlimages/curl:8.22.0` rodando o script de inicialização (one-shot) | As aplicações GitHub constroem o repositório `novapromotora-labs/monalisa`, branch `main`, com **auto-deploy ligado** (ver [Auto-deploy](#auto-deploy)). ## Variáveis em três camadas [#variáveis-em-três-camadas] - **Projeto:** valores comuns a todos os ambientes (pool e timeouts do banco, `CORS_CREDENTIALS`). - **Ambiente staging:** `DATABASE_URL`, `REDIS_URL`, o endpoint `MONALISA_SQS_*` do ElasticMQ, `LOG_LEVEL`, `CORS_ORIGINS` e o token de administração do Garage. - **Aplicação:** só o que cada uma usa, quase sempre como referência `${{project.NOME}}` ou `${{environment.NOME}}`, que o Dokploy resolve no deploy. ## `dokploy:apply`: convergir [#dokployapply-convergir] ```sh bun run dokploy:apply --dry-run # só o plano: nomes de variáveis, nunca valores bun run dokploy:apply # converge; uma segunda execução informa "no changes" ``` A chave da API do Dokploy vem do cofre do operador e nunca é commitada, impressa ou registrada. - Encontra ou cria ambiente, bancos e aplicações pelo nome e altera só os campos que diferem. - Mescla cada camada de variáveis com o que o Dokploy já tem: variáveis que ele não gerencia sobrevivem. `DATABASE_URL` e `REDIS_URL` são reconstruídas a cada execução a partir da senha e do host que o Dokploy guarda, então acompanham um banco recriado. - Segredos gerados (`PLATFORM_BOOTSTRAP_PASSWORD`, os segredos do Garage e as senhas dos bancos na criação) só são criados quando faltam e nunca são sobrescritos. A saída nomeia variáveis, nunca valores. - Uma variável removida de `staging.config.ts` não é apagada do Dokploy: remova-a na interface. - A imagem de um banco só é atualizada no lugar dentro da mesma linha (repositório, major e variante: `postgres:18.6-alpine` → `postgres:18.7-alpine`). Outra linha para com erro: um major do PostgreSQL não lê o diretório de dados de outro, e o Valkey 8 não carrega o formato RDB do Redis 7.4. Nesses casos o serviço precisa de backup, remoção na interface e recriação pelo `apply`. O mesmo vale para o `garage`, preso a `dxflrs/garage:v2.*`: uma major do Garage muda o formato em disco. - Sem o domínio S3 configurado, o `apply` pula os backups com um aviso. `PLATFORM_BOOTSTRAP_PASSWORD` é gerada pelo primeiro `apply` e fica só no ambiente do migrator. Ela não é impressa, a não ser com `--reveal-bootstrap-password`, que escreve só a senha no stdout e é recusado quando o stdout é um terminal. Só o primeiro deploy a lê. ## `dokploy:deploy`: publicar em ordem [#dokploydeploy-publicar-em-ordem] ```sh bun run dokploy:deploy # a partir de um checkout de main em dia ``` 1. Sobe PostgreSQL e Redis se não estão rodando e espera o Dokploy reportá-los no ar; depois o ElasticMQ, se não está rodando. 2. Publica o migrator. O status de deploy do Dokploy cobre o build e a atualização do serviço, não o código de saída de um one-shot; por isso o comando lê as tarefas do swarm do migrator e só continua quando a tarefa nova termina `Complete`. Tarefa falha ou rejeitada, saída diferente de zero, nenhuma tarefa nova ou resposta ilegível param o deploy antes dos workers e da API. 3. Publica os workers e a API. 4. Consulta `https:///ready` até responder 200 **e** informar o `latestMigration` esperado: a migration mais nova do checkout local (por isso rodar a partir do commit que o Dokploy constrói), ou `--expect-migration `. Um 200 de uma tarefa antiga da API, ainda servindo durante a atualização, não passa. 5. Só então cuida do armazenamento de backups: sobe o Garage se não está rodando e roda o `garage-init` (conferido como `Complete` do mesmo jeito) até ele ter terminado uma vez, ou de novo com `--reinit`. Uma falha do Garage ou do `garage-init` nunca segura a versão: o comando falha no fim, dizendo que a versão está no ar. Cada requisição tem timeout e cada espera tem prazo de 20 minutos. O migrator (como o `garage-init`) é one-shot e roda com restart `none` e update `FailureAction: continue`, `Monitor: 0`. Sem isso, uma tarefa que termina com 0 dentro da janela de monitoramento contaria como atualização falha e o swarm voltaria o serviço à especificação anterior (com um `DATABASE_URL` antigo), e quem decide se tenta de novo é o comando de deploy, não o swarm. ## Auto-deploy [#auto-deploy] O auto-deploy fica **ligado por padrão** para o migrator, a API e os workers (`autoDeploy` em `staging.config.ts`; `dokploy:apply --no-auto-deploy` desliga até o próximo `apply` sem a flag). A cada push em `main`, o Dokploy republica os três, **cada um por conta própria, sem ordem entre eles**. O [portão de prontidão](/migrations/deploy#o-portão-de-prontidão) garante a **correção**: a API e os workers novos ficam não prontos (`/ready` responde 503, os workers não consomem) até o migrator aplicar a migration do build, então uma API nova nunca serve sobre um schema antigo. Ele **não** garante que o deploy **convirja**: - ninguém lê o código de saída do migrator, então um migrator que falha passa despercebido; - uma tarefa nova da API que continua não pronta além do healthcheck, enquanto o migrator ainda roda, pode deixar a atualização do swarm parada na tarefa antiga (o Dokploy não define ação de falha de atualização para a API e os workers). Por isso, **depois de um push, confira a versão**: o `/ready` precisa informar o `latestMigration` novo. Se não informar, republique a API (ou o migrator, se ele falhou), ou rode `bun run dokploy:deploy`, o caminho ordenado que confere cada passo. Se a instância do Dokploy tiver notificações configuradas, ligue a de deploy falho. Quando a versão adiciona uma task ou um assinante, os workers podem subir depois da API; mensagens para uma unidade ainda desconhecida voltam para a fila com backoff (ver [Ordem de deploy](/background/tasks-e-eventos#ordem-de-deploy)). ## Outros cuidados [#outros-cuidados] - O `deploy` não mexe num ElasticMQ nem num Garage que estão rodando: uma mudança em `elasticmq.conf` (gravada pelo `apply`) só vale depois de republicar o `elasticmq` na interface; o mesmo vale para `garage.toml` e o `garage`. - As filas do ElasticMQ são em memória: reiniciá-lo perde mensagens que o relay já enviou, e as runs delas ficam em `queued`. - Staging não tem agendador externo, então tarefas agendadas (como `maintenance.purge`) não rodam lá. - O `/ready` expõe `latestMigration` publicamente; produção precisa reavaliar isso. # Política de versões (/operacao/versoes) Toda dependência tem versão exata (NOVA-116). Um build muda só quando um commit muda uma versão. ## Onde as versões ficam [#onde-as-versões-ficam] | O quê | Regra | | --- | --- | | Pacotes | Todo `package.json` usa `x.y.z` exato (ou `workspace:*`). `bun.lock` é instalado com `--frozen-lockfile` em todo lugar. `bun run versions:check`, no CI, recusa `^`, `~`, `*`, `x`, `latest`, `>`, `<` e dist-tags. | | Bun | A raiz fixa o Bun (`packageManager` e `engines`); o `Dockerfile`, o CI e as instalações locais usam a mesma versão. | | Imagens | Tag exata mais o digest do índice multi-arquitetura (`imagem:tag@sha256:…`) no `Dockerfile`, no `compose.yaml`, no script de testes e nos workflows. A configuração de staging leva só a tag exata. | | GitHub Actions | Todo `uses:` aponta para o SHA completo de um commit, com a versão em comentário (`# v7.0.1`). | Para mover uma imagem, leia o digest novo com `docker buildx imagetools inspect ` (o `Digest` do topo) e atualize, no mesmo commit, todo arquivo que cita essa imagem. Para uma action, resolva a tag até o commit; quando a tag é anotada, siga até o commit para o qual ela aponta. ## Quando atualizar [#quando-atualizar] - **Varredura mensal de patches:** versões patch de pacotes, imagens, Bun e actions juntas, num PR só, com o gate completo (testes de integração, builds das imagens e o smoke delas). - **Major, ou minor com notas de quebra:** um PR por dependência, com as notas do upstream que importam aqui na descrição. - **MikroORM:** cada atualização revisa o patch aplicado ao Kysely (que o MikroORM fixa): a versão do Kysely, o código corrigido e os testes de regressão de limpeza de transação. Uma instalação congelada falha quando o patch deixa de aplicar. - **Banco em major nova:** não é atualização no lugar. PostgreSQL e Valkey não leem os dados de outra major, então os volumes locais ganham nome novo e o serviço do Dokploy é recriado (ver [Staging no Dokploy](/operacao/staging-dokploy)). ## Versões atuais [#versões-atuais] | Componente | Versão | | --- | --- | | Bun | 1.4.2 | | PostgreSQL | 18.6 | | Valkey | 8.1.10 | | ElasticMQ | 1.7.1 | | MikroORM | 7.2.4 | | TypeScript | 7.0.2 | Esta documentação segue a mesma política: versões exatas no `package.json`, `bun.lock` commitado e imagens fixadas por digest. # Rodar localmente (/rodar-localmente) ## Com Docker e Make [#com-docker-e-make] Requisitos: Bun 1.4.2, Make, OpenSSL e Docker com Compose. ```sh make setup # instala dependências pelo lockfile e cria .env.compose com senhas locais aleatórias make build # imagens da API, do migrator e dos workers make dev # migra, semeia e sobe tudo com hot reload ``` `make setup` preserva um `.env.compose` existente. Nele ficam as portas locais, `CORS_ORIGINS` e o username e o e-mail do primeiro operador de plataforma; a senha dele (`PLATFORM_BOOTSTRAP_PASSWORD`) é gerada ali. `make dev` roda o migrator (migrations e seeders), depois sobe a API com `bun --watch` em `http://127.0.0.1:3000` junto com PostgreSQL, Valkey, ElasticMQ e dois workers. `MONALISA_HTTP_PORT` muda a porta. PostgreSQL e Valkey ficam na rede privada, sem porta publicada; o ElasticMQ fica em `127.0.0.1:9324`. | Comando | O que faz | | --- | --- | | `make migrate` | Só aplica as migrations | | `make seed` | Só roda os seeders padrão | | `make up` | Pilha no modo das imagens de produção (migrator até terminar, depois API, workers e ElasticMQ) | | `make status` / `make logs` | Estado e logs da pilha | | `make test` | Suíte de testes com serviços descartáveis | | `make down` | Para a pilha e preserva os volumes | O projeto Compose padrão se chama `monalisa`. Para outra pilha em paralelo, use `COMPOSE_PROJECT_NAME` (ou `PROJECT`) e `ENV_FILE`, com os mesmos valores nos comandos seguintes. ### Workers no modo dev [#workers-no-modo-dev] Em `make dev` há dois serviços de workers: `workers` consome `bulk,maintenance` e roda o relay e o agendador local; `workers-online` consome `critical,default`. O agendador local só existe em desenvolvimento. ## Com serviços externos [#com-serviços-externos] Requisitos: Bun 1.4.2, PostgreSQL 18 e Redis ou Valkey. Docker continua necessário para os testes que criam serviços descartáveis. ```sh bun install --frozen-lockfile cp apps/api/.env.example apps/api/.env cp apps/migrator/.env.example apps/migrator/.env ``` Use o mesmo `DATABASE_URL` nos dois arquivos e `REDIS_URL` no da API. No do migrator, preencha `PLATFORM_BOOTSTRAP_USERNAME`, `PLATFORM_BOOTSTRAP_EMAIL` e `PLATFORM_BOOTSTRAP_PASSWORD`. Os arquivos `.env` nunca são commitados. ```sh bun run permissions:emit # o seeder lê o registry de permissões bun run --cwd apps/migrator migrate status bun run --cwd apps/migrator migrate apply bun run db:seed # dados de referência + primeiro operador de plataforma bun run db:seed --class DevSeeder # opcional: dados locais; recusado com NODE_ENV=production bun run --cwd apps/api dev ``` O `predev` da API emite o catálogo de tasks (`tasks.json`) e a versão de schema esperada antes de subir. Iniciada de outro jeito, a API precisa de `MONALISA_TASKS_CATALOG` e `MONALISA_SCHEMA_VERSION`, e os workers de `MONALISA_SCHEMA_VERSION` (`bun run tasks:emit` e `bun run schema-version:emit` geram os arquivos). ## Verificações [#verificações] ```sh bun run typecheck bun test # pula os testes de serviços externos sem as variáveis de integração bun run test:integration # cria PostgreSQL, Valkey e ElasticMQ descartáveis bun run migrations:check bun run openapi:check bun run permissions:check bun run versions:check bun run format:check ``` Os testes de integração exigem um banco cujo nome termine em `_test`; nunca aponte as fixtures para um banco com dados a preservar.