{
  "schema_version": 1,
  "titulo": "Banco de dados externo",
  "descricao": "A organização cadastra um PostgreSQL de OUTRO sistema (segundo CRM, ERP) e o consulta de dentro do CRM — pela tela e pela IA que atende. O cadastro é de admin; a leitura é de qualquer autenticado. Schema lido ao vivo, nada hard-coded. Recortes do PR #1130, de @vgamkt.",
  "lanes": [
    {
      "id": "dados",
      "label": "Cadastro e segredo (Supabase)"
    },
    {
      "id": "nucleo",
      "label": "Núcleo lib/external-db"
    },
    {
      "id": "api",
      "label": "API /api/v1/external-db"
    },
    {
      "id": "ui",
      "label": "Tela /app/integracao-dados"
    },
    {
      "id": "agente",
      "label": "Agente (tools MCP)"
    }
  ],
  "mainPath": [
    "migration0372",
    "credenciais",
    "acesso",
    "drivers",
    "dialeto",
    "apiLeitura",
    "telaExplorador",
    "apiReceitas",
    "toolReceita"
  ],
  "nodes": [
    {
      "id": "migration0372",
      "lane": "dados",
      "label": "external_db_connections",
      "sublabel": "migration 0372 · tenant-aware, RLS, senha AES-GCM · 0691 soma db_type e last_test_aviso",
      "type": "table",
      "dot": "sky"
    },
    {
      "id": "safeview",
      "lane": "dados",
      "label": "external_db_connections_safe",
      "sublabel": "view sem as colunas cifradas · a única que a tela lê · 0691 soma db_type e last_test_aviso",
      "type": "table",
      "dot": "sky"
    },
    {
      "id": "auditlog",
      "lane": "dados",
      "label": "api_audit_log",
      "sublabel": "mutações e leituras — sem os valores de filtro (PII)",
      "type": "table",
      "dot": "slate"
    },
    {
      "id": "credenciais",
      "lane": "nucleo",
      "label": "credenciais.ts",
      "sublabel": "leitura SEMPRE com organization_id; cifra just-in-time",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "guardas",
      "lane": "nucleo",
      "label": "guardas.ts",
      "sublabel": "política de rede: LAN permitida, link-local/loopback sempre bloqueados; IPv6 normalizado",
      "type": "lib",
      "dot": "red"
    },
    {
      "id": "pool",
      "lane": "nucleo",
      "label": "conexao.ts",
      "sublabel": "pool por conexão, invalidado por updated_at; BEGIN READ ONLY + timeouts",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "introspeccao",
      "lane": "nucleo",
      "label": "introspeccao.ts",
      "sublabel": "catálogo ao vivo: tabelas, campos, chave, estimativa",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "leitura",
      "lane": "nucleo",
      "label": "leitura.ts",
      "sublabel": "SELECT montado no servidor: identificadores quotados, valores parametrizados, teto de linhas",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "acesso",
      "lane": "nucleo",
      "label": "acesso.ts",
      "sublabel": "carrega a conexão + revalida o destino antes de abrir o pool",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "drivers",
      "lane": "nucleo",
      "label": "drivers.ts",
      "sublabel": "escolhe o motor pela conexão: abre o dialeto, testa e fecha o pool",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "dialeto",
      "lane": "nucleo",
      "label": "dialeto.ts",
      "sublabel": "o ÚNICO caminho de leitura: aplica as fontes liberadas e abre o pool",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "fontes",
      "lane": "nucleo",
      "label": "fontes.ts",
      "sublabel": "esquema central das fontes liberadas + o que é visível (puro)",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "apiConnections",
      "lane": "api",
      "label": "GET/POST/PATCH/DELETE connections",
      "sublabel": "lista: autenticado · criar/editar/apagar: admin · valida o host no cadastro",
      "type": "api",
      "dot": "green"
    },
    {
      "id": "apiTest",
      "lane": "api",
      "label": "POST connections/[id]/test",
      "sublabel": "Client descartável, grava last_test_*; 200 mesmo em falha do teste",
      "type": "api",
      "dot": "green"
    },
    {
      "id": "apiLeitura",
      "lane": "api",
      "label": "GET .../schemas e .../tables/[schema]/[tabela]",
      "sublabel": "autenticado · sem filtro na querystring (PII não vai a log)",
      "type": "api",
      "dot": "green"
    },
    {
      "id": "apiFontes",
      "lane": "api",
      "label": "GET catalog · GET/PUT sources",
      "sublabel": "catálogo completo e troca da lista: admin · ler a lista: autenticado",
      "type": "api",
      "dot": "green"
    },
    {
      "id": "telaLista",
      "lane": "ui",
      "label": "Lista de conexões",
      "sublabel": "todos veem · card de cadastro/edição só para admin · selo do motor e aviso do teste",
      "type": "route",
      "dot": "purple"
    },
    {
      "id": "telaExplorador",
      "lane": "ui",
      "label": "Explorador",
      "sublabel": "árvore por schema + grade paginada com ordem e PK",
      "type": "route",
      "dot": "purple"
    },
    {
      "id": "telaFontes",
      "lane": "ui",
      "label": "Painel \"O que o assistente pode ver\"",
      "sublabel": "admin marca tabelas e colunas, descreve e salva · os demais só leem",
      "type": "route",
      "dot": "purple"
    },
    {
      "id": "toolsAgente",
      "lane": "agente",
      "label": "crm_describe / crm_query_external_data",
      "sublabel": "lib/mcp/tools/dados-externos.ts · tetos da conexão · aviso anti prompt-injection · vazio fica vazio",
      "type": "lib",
      "dot": "amber"
    },
    {
      "id": "ingressos",
      "lane": "agente",
      "label": "Ingressos: turno do agente e /api/mcp",
      "sublabel": "aplicam redigirParaAuditoria (tira os valores de filtro) e motivoDoVazio (erro não é sucesso)",
      "type": "lib",
      "dot": "amber"
    },
    {
      "id": "receitasColuna",
      "lane": "dados",
      "label": "receitas (jsonb) em external_db_connections",
      "sublabel": "migration 0693 · instâncias de receita por org · RLS herda da tabela · fora do _safe",
      "type": "table",
      "dot": "sky"
    },
    {
      "id": "receitasCount",
      "lane": "dados",
      "label": "receitas_count na view _safe",
      "sublabel": "jsonb_array_length(receitas) · última coluna da view · contagem só",
      "type": "table",
      "dot": "sky"
    },
    {
      "id": "instanciaReceita",
      "lane": "nucleo",
      "label": "receitas/*.ts",
      "sublabel": "DefinicaoDeReceita, registro, validador, instancias (Zod central) · tabelas e colunas saem dos FIXOS (a receita só pede as tabelas de categoria quando há categoria mapeada)",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "precoReceita",
      "lane": "nucleo",
      "label": "preco.ts",
      "sublabel": "converte preço de texto (650.000,00 / 650,000.00 / 480000) em número no SQL da receita · sem preço vira NULL, nunca 0",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "taxonomiaReceita",
      "lane": "nucleo",
      "label": "taxonomia.ts",
      "sublabel": "as 3 tabelas de categoria do WordPress (terms, term_taxonomy, term_relationships) · saída por group_concat (post com 2 termos sai 1 vez) · filtro por exists · nível filho/raiz",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "modelosReceita",
      "lane": "nucleo",
      "label": "modelos-wordpress.ts",
      "sublabel": "o MODELO \"Imóveis\" e o \"Em branco\" · só configuração que pré-preenche a tela · o nicho NÃO mora na receita",
      "type": "lib",
      "dot": "blue"
    },
    {
      "id": "errosReceita",
      "lane": "nucleo",
      "label": "erros.ts",
      "sublabel": "ErroDeFiltroDesconhecido · filtro que a instância não tem é ERRO legível, nunca silêncio (devolver a lista inteira mentiria sobre o filtro)",
      "type": "lib",
      "dot": "red"
    },
    {
      "id": "toolReceita",
      "lane": "agente",
      "label": "crm_consultar_receita",
      "sublabel": "listar/executar instâncias · organização do ctx · modulo banco_externo · rastro sem valores",
      "type": "lib",
      "dot": "amber"
    },
    {
      "id": "apiReceitas",
      "lane": "api",
      "label": "PUT/POST connections/[id]/recipes",
      "sublabel": "grava instâncias e pré-visualiza (admin) · auditoria só contagens",
      "type": "api",
      "dot": "green"
    }
  ],
  "edges": [
    {
      "id": "e1",
      "from": "migration0372",
      "to": "safeview",
      "label": "expõe sem cifra"
    },
    {
      "id": "e2",
      "from": "migration0372",
      "to": "credenciais",
      "label": "linha decifrada"
    },
    {
      "id": "e3",
      "from": "safeview",
      "to": "apiConnections",
      "label": "lista e detalhe"
    },
    {
      "id": "e6",
      "from": "credenciais",
      "to": "acesso",
      "label": "conexão decifrada"
    },
    {
      "id": "e7",
      "from": "credenciais",
      "to": "apiTest",
      "label": "carrega para testar"
    },
    {
      "id": "e8",
      "from": "guardas",
      "to": "acesso",
      "label": "revalida o destino"
    },
    {
      "id": "e9",
      "from": "guardas",
      "to": "apiConnections",
      "label": "valida no cadastro"
    },
    {
      "id": "e11",
      "from": "pool",
      "to": "introspeccao",
      "label": "consultar read-only"
    },
    {
      "id": "e12",
      "from": "pool",
      "to": "leitura",
      "label": "consultar read-only"
    },
    {
      "id": "e13",
      "from": "introspeccao",
      "to": "dialeto",
      "label": "catálogo"
    },
    {
      "id": "e15",
      "from": "leitura",
      "to": "dialeto",
      "label": "dados paginados"
    },
    {
      "id": "e17",
      "from": "acesso",
      "to": "apiLeitura",
      "label": "dialeto pronto"
    },
    {
      "id": "e20",
      "from": "apiConnections",
      "to": "telaLista",
      "label": "lista"
    },
    {
      "id": "e21",
      "from": "telaLista",
      "to": "apiConnections",
      "label": "criar/editar/testar"
    },
    {
      "id": "e22",
      "from": "apiConnections",
      "to": "auditlog",
      "label": "audita mutação"
    },
    {
      "id": "e23",
      "from": "apiTest",
      "to": "auditlog",
      "label": "audita teste"
    },
    {
      "id": "e24",
      "from": "apiLeitura",
      "to": "telaExplorador",
      "label": "grade"
    },
    {
      "id": "e25",
      "from": "telaLista",
      "to": "telaExplorador",
      "label": "abre a conexão"
    },
    {
      "id": "e30",
      "from": "safeview",
      "to": "toolsAgente",
      "label": "conexões ativas da org"
    },
    {
      "id": "e31",
      "from": "acesso",
      "to": "toolsAgente",
      "label": "dialeto pronto"
    },
    {
      "id": "e34",
      "from": "toolsAgente",
      "to": "ingressos",
      "label": "resultado + args redigidos"
    },
    {
      "id": "e35",
      "from": "ingressos",
      "to": "auditlog",
      "label": "audita sem valores de filtro"
    },
    {
      "id": "e36",
      "from": "credenciais",
      "to": "fontes",
      "label": "modo + lista (validados, falha fechada)"
    },
    {
      "id": "e37",
      "from": "fontes",
      "to": "dialeto",
      "label": "o que é visível"
    },
    {
      "id": "e39",
      "from": "dialeto",
      "to": "apiLeitura",
      "label": "só o liberado"
    },
    {
      "id": "e40",
      "from": "dialeto",
      "to": "toolsAgente",
      "label": "só o liberado"
    },
    {
      "id": "e41",
      "from": "apiFontes",
      "to": "migration0372",
      "label": "grava source_mode e sources"
    },
    {
      "id": "e42",
      "from": "dialeto",
      "to": "apiFontes",
      "label": "catálogo completo (admin)"
    },
    {
      "id": "e43",
      "from": "telaFontes",
      "to": "apiFontes",
      "label": "lê o catálogo completo e grava a lista"
    },
    {
      "id": "e44",
      "from": "telaLista",
      "to": "telaFontes",
      "label": "abre o painel (conexão nova ou sem fontes)"
    },
    {
      "id": "e45",
      "from": "telaFontes",
      "to": "telaExplorador",
      "label": "a grade passa a mostrar só o liberado"
    },
    {
      "id": "e46",
      "from": "acesso",
      "to": "drivers",
      "label": "abre o dialeto pelo motor da conexão"
    },
    {
      "id": "e47",
      "from": "drivers",
      "to": "dialeto",
      "label": "cria o dialeto do motor"
    },
    {
      "id": "e48",
      "from": "pool",
      "to": "drivers",
      "label": "pool do PostgreSQL"
    },
    {
      "id": "e49",
      "from": "apiTest",
      "to": "drivers",
      "label": "testa pelo driver do motor"
    },
    {
      "id": "e50",
      "from": "apiConnections",
      "to": "drivers",
      "label": "fecha o pool ao editar ou apagar"
    },
    {
      "id": "e51",
      "from": "migration0372",
      "to": "receitasColuna",
      "label": "coluna receitas (0693)"
    },
    {
      "id": "e52",
      "from": "receitasColuna",
      "to": "receitasCount",
      "label": "contagem exposta"
    },
    {
      "id": "e53",
      "from": "credenciais",
      "to": "instanciaReceita",
      "label": "linha com receitas"
    },
    {
      "id": "e54",
      "from": "instanciaReceita",
      "to": "drivers",
      "label": "abre o dialeto"
    },
    {
      "id": "e55",
      "from": "drivers",
      "to": "toolReceita",
      "label": "dialeto pronto"
    },
    {
      "id": "e56",
      "from": "toolReceita",
      "to": "apiReceitas",
      "label": "mesma conexão"
    },
    {
      "id": "e57",
      "from": "apiReceitas",
      "to": "telaExplorador",
      "label": "seção Receitas prontas"
    },
    {
      "id": "e58",
      "from": "telaFontes",
      "to": "apiReceitas",
      "label": "grava instâncias"
    },
    {
      "id": "e59",
      "from": "apiReceitas",
      "to": "auditlog",
      "label": "audita sem valores"
    },
    {
      "id": "e60",
      "from": "apiReceitas",
      "to": "receitasCount",
      "label": "contagem exposta pela API"
    },
    {
      "id": "e61",
      "from": "instanciaReceita",
      "to": "precoReceita",
      "label": "a receita do WordPress monta o CASE do preço"
    },
    {
      "id": "e62",
      "from": "precoReceita",
      "to": "instanciaReceita",
      "label": "o CASE entra no SQL da receita (sem novo placeholder)"
    },
    {
      "id": "e63",
      "from": "instanciaReceita",
      "to": "taxonomiaReceita",
      "label": "a receita do WordPress lê a categoria do site"
    },
    {
      "id": "e64",
      "from": "taxonomiaReceita",
      "to": "instanciaReceita",
      "label": "a subconsulta (group_concat) e o exists entram no SQL da receita"
    },
    {
      "id": "e65",
      "from": "modelosReceita",
      "to": "instanciaReceita",
      "label": "o modelo \"Imóveis\" preenche a configuração que a tela grava"
    },
    {
      "id": "e68",
      "from": "apiReceitas",
      "to": "modelosReceita",
      "label": "a tela aplica o modelo ao escolher a receita"
    },
    {
      "id": "e66",
      "from": "instanciaReceita",
      "to": "errosReceita",
      "label": "filtro que a instância não tem: erro legível, nunca silêncio"
    },
    {
      "id": "e67",
      "from": "errosReceita",
      "to": "toolReceita",
      "label": "a tool devolve o erro com a lista dos filtros que a instância aceita"
    }
  ],
  "cards": [
    {
      "id": "card-leitura",
      "tone": "blue",
      "title": "Somente leitura, imposta pelo banco",
      "body": "Toda consulta roda dentro de `BEGIN READ ONLY` com statement_timeout/lock_timeout. Mesmo que algo escapasse da montagem do SELECT, o Postgres recusaria a escrita. O teto de linhas (200) existe para uma tabela de milhões não derrubar o worker."
    },
    {
      "id": "card-rede",
      "tone": "red",
      "title": "A guarda de rede roda a CADA abertura de pool",
      "body": "Validar só no cadastro deixaria a janela do DNS binding aberta entre o POST e o primeiro SELECT. `abrirAcesso` revalida o destino em toda leitura. RFC1918 é permitido (Postgres na LAN é caso real); link-local/metadata, loopback e CGNAT nunca."
    },
    {
      "id": "card-pii",
      "tone": "amber",
      "title": "O audit guarda o QUE foi lido, nunca o CONTEÚDO",
      "body": "A leitura externa pode carregar PII. As ações `external_db_connection.*` registram a conexão, o schema e a tabela; nenhum valor de filtro e nenhuma linha lida entram em `api_audit_log`. Log é lugar de metadado, não de conteúdo."
    },
    {
      "id": "card-fontes-liberadas",
      "tone": "slate",
      "title": "D6 revisada: o administrador marca o que o assistente pode ler",
      "body": "A Spec 20 (D6) deixava a IA ler QUALQUER tabela da conexão, com o argumento de que uma lista seria uma segunda lista para manter em sincronia com um schema que muda todo dia. A Spec 23 revisa isso: a lista guarda NOMES (não cópia de schema), fonte que sumiu do banco é ignorada e sinalizada, e 'marcar todas' é uma lista explícita — não o modo `all`. Conexões que já existem ficam em `all` (a D6 original); as novas nascem em `list`. As travas anteriores continuam: somente leitura, tetos e auditoria sem valores."
    },
    {
      "id": "card-vazio-fica-vazio",
      "tone": "amber",
      "title": "Filtro sem resultado devolve vazio, nunca a tabela inteira",
      "body": "A versão do PR reexecutava sem o filtro e entregava até 100 linhas ao modelo, pensando num catálogo de produtos. A tool é genérica: numa tabela de clientes, o CPF que não casa entregaria os registros de outras pessoas. O vazio fica vazio, e a resposta ensina o modelo a repetir com um trecho menor do termo."
    }
  ]
}
