Saltar para o conteúdo
BeryX
Voltar às respostas para RFP

Documentação do BeryX

BeryX — Guia de conectores

Como obter credenciais e configurar cada fonte de dados no wizard de conexões. Contrato técnico em docs/ARQUITETURA.md §3.1; implementação em backend/app/infra/connectors/.

Princípios comuns a todas as fontes:

  • Read-only: o BeryX apenas lê metadados, agregados e amostras — nunca escreve na fonte. Usa sempre os métodos oficiais dos vendors.
  • Credenciais cifradas: a configuração é cifrada com Fernet antes de ser persistida e nunca aparece em logs nem em respostas da API.
  • Fluxo no wizard: criar conexão → Testar ligação (mensagem de diagnóstico se falhar) → Descobrir datasets → agendar/executar scans.
  • IP de saída fixo: com o Cloud NAT ativo (DEPLOY_GCP §12), todas as ligações às fontes saem de um IP fixo por região. O wizard mostra-o («Os nossos pedidos vêm de …») e também está em GET /api/v1/public/egress-ips. Em produção ainda está por ativar: enquanto esse endpoint devolver configured: false, o wizard esconde a nota e a saída não tem IP fixo. Portas por conector, TLS e conectividade privada: REDE_ALLOWLIST.md.
  • Boa vizinhança com o DBA: todas as queries dos conectores SQL saem identificadas (application_name/program_name/QUERY_TAG e o comentário /* DataVault scan <uuid> */), e cada conexão pode ter janelas de blackout, um máximo de queries em simultâneo, uma amostragem máxima e uma réplica de leitura (replica_host/replica_port em Postgres, MySQL e SQL Server). O relatório do que foi executado exporta-se em CSV/JSON. Guia completo: DBA.md.
  • Drivers opcionais: cada fonte tem um extra pip próprio. Sem o extra instalado, a app funciona na mesma: GET /api/v1/connections/types devolve available=false e o missing_extra do tipo, e o wizard mostra «driver não incluído nesta instalação» em vez de deixar criar uma conexão que falharia no teste. O mapa tipo → extra → módulos vive em backend/app/infra/connectors/extras.py (fonte única). O mesmo endpoint devolve o nome (label), a família (group) e a âncora da secção deste guia (docs_anchor) de cada tipo — é daí que a página Ajuda tira a lista de conectores suportados; um título novo aqui que mude uma âncora faz falhar tests/test_connection_types.py.

Resumo

Fonte Tipo (connector_type) Extra pip Campos do wizard
PostgreSQL postgres datavault-backend[postgres] (psycopg2) host, port, database, user, password (ou url); replica_host?, replica_port?, tls_mode?, ssl_ca_pem?
MySQL/MariaDB mysql datavault-backend[mysql] host, port, database, user, password (ou url mysql+pymysql://); replica_host?, replica_port?, tls_mode?, ssl_ca_pem?
SQLite sqlite — (incluído) path (ficheiro .db; usado em testes/demos)
CSV csv datavault-backend[csv] (opcional) path (diretório com ficheiros .csv)
Google Cloud Storage gcs datavault-backend[gcs] (gcsfs + pandas + pyarrow) bucket, prefix?, service_account_info (chave JSON) ou anonymous: true; max_files?, include_images?
Amazon S3 (e compatíveis) s3 datavault-backend[s3] (s3fs + pandas + pyarrow) bucket, prefix?, access_key_id + secret_access_key ou anonymous: true; region?, endpoint_url?, max_files?, include_images?
Azure Blob Storage / ADLS Gen2 adls datavault-backend[adls] (adlfs + pandas + pyarrow) account_name, container, prefix? e uma credencial: account_key, sas_token, tenant_id + client_id + client_secret ou anonymous: true; endpoint_suffix?, max_files?, include_images?
SFTP (file drops de parceiros e bancos) sftp datavault-backend[sftp] (paramiko + pandas + pyarrow) host, port? (22), username, password ou private_key_pem (+ passphrase?), host_key_fingerprint (obrigatório em produção), root_path, prefix?, max_files?, include_images?
BigQuery bigquery datavault-backend[bigquery] project, dataset?, credentials_json (obrigatório em produção); max_bytes_per_query?, max_bytes_per_scan?, partition_window_days?
Databricks databricks datavault-backend[databricks] host, access_token, http_path, catalog?, schema?
Snowflake snowflake datavault-backend[snowflake] account, user, password, database, schema?, warehouse?, role?
Salesforce salesforce datavault-backend[salesforce] username, password, security_token, domain
Dynamics 365 dynamics datavault-backend[dynamics] org_url, tenant_id, client_id, client_secret, entities?
Microsoft Fabric fabric datavault-backend[fabric] + driver ODBC server, database, tenant_id, client_id, client_secret
SQL Server / Azure SQL / Synapse mssql datavault-backend[mssql] (pymssql); modo Entra ID: + [fabric] e driver ODBC host, port?, database, user, password ou tenant_id, client_id, client_secret; application_intent?, replica_host?, replica_port? (ou url)
Oracle Database oracle datavault-backend[oracle] (oracledb, modo thin) host, port?, service_name ou sid, user, password, schemas? (ou url)
MongoDB / Atlas mongodb datavault-backend[mongodb] (pymongo) uri ou host, port?, user?, password?, auth_source?; database, collections?, sample_size?, tls_mode?, ssl_ca_pem?
Confluence Cloud (documental) confluence — (incluído) site, email, api_token, space_key?, mine_signals?
Jira Cloud (documental, sinais) jira — (incluído) site, email, api_token, project_key?, jql?, mine_signals?
SharePoint / Teams (documental) sharepoint datavault-backend[sharepoint] site_urls, tenant_id, client_id, client_secret, folder?, drive_names?, include_images?
Google Drive (documental) gdrive datavault-backend[gdrive] credentials_json, folder_ids e/ou shared_drive_id, folder?, subject?, include_images?
Google Sheets gsheets datavault-backend[gdrive] credentials_json, spreadsheet_ids, header_row?, sheets?, subject?
Slack — canais (sinais) slack — (incluído) bot_token (xoxb-), channels, site?, lookback_days?, mine_signals?
Teams — canais (sinais) teams datavault-backend[sharepoint] tenant_id, client_id, client_secret, channels, lookback_days?, mine_signals?
Apache Kafka (streaming) kafka datavault-backend[kafka] bootstrap_servers, security_protocol?, sasl_mechanism?, sasl_username?, sasl_password?, topics?, value_format?, sample_size?, consumer_group?, schema_registry_url?, schema_registry_username?, schema_registry_password?, ssl_ca_pem?
Google Pub/Sub (streaming) pubsub datavault-backend[pubsub] credentials_json, subscriptions, project_id?, value_format?, sample_size?, sample_window_seconds?, consumer_subscriptions?
SAP S/4HANA / Gateway (OData) sap — (incluído) base_url, auth? (basic, oauth2_client_credentials ou api_key) com username + password, token_url + client_id + client_secret + scope? ou api_key + api_key_header?; sap_client?, entity_sets?, sample_size?
OData v2/v4 (genérico) odata — (incluído) base_url, auth? (none, basic, oauth2_client_credentials ou api_key) e as credenciais do modo, como no sap; sap_client?, entity_sets?, sample_size?
API REST/JSON (genérica) rest_api — (incluído) base_url, auth? (none, bearer, basic ou api_key) com token, username + password ou api_key + api_key_header?; endpoints, records_path?, pagination?, total_path?, allowed_hosts?, contract?, sample_size?, max_pages?

Estado de validação

Prova viva, por conector, de que a sequência que o produto usa funciona: test_connection → list_tables (ou list_documents nas fontes documentais) → get_columns → profile_table (1 tabela, amostra pequena) → fetch_rows(limit=5) e, quando o conector o implementa, table_metadata. Corre com backend/scripts/connector_matrix.py (lógica em app/infra/connectors/conformity.py) e cada linha fica classificada pelo tier mais forte que passou:

Classificação O que prova Onde corre
real_public Fonte pública real, sem conta (presets PUBDATA: RNAcentral, Rfam, Ensembl, OpenAQ, NOAA GHCN, feriados do Azure Open Datasets; GCS cloud-samples-data/bigquery/us-states; serviços de referência do OData.org, Northwind v2 e TripPin v4) e fixtures locais SQLite/CSV (seed/connectors/) --tier public — sem infra, local ou CI
real_docker Serviço real em docker-compose.connectors.yml (postgres:16, mysql:8, mongo:7, Redpanda, MinIO, Azurite) com o seed de seed/connectors/ --tier docker — job docker do workflow connectors-matrix.yml
real_sandbox Conta de teste do vendor (BigQuery austin_311 com chave de SA, Snowflake trial, Databricks, Salesforce Developer Edition, sandbox do SAP Business Accelerator Hub com API key, Confluence/Jira Cloud free, Slack de teste, projeto GCP de teste para Pub/Sub…) --tier sandbox — só tipos com os secrets DATAVAULT_MATRIX_<TIPO>_<CHAVE> todos definidos
fake_transport O contrato do conector (parsing, paginação, guardas) contra os transportes falsos dos testes (backend/tests/fakes/connectors.py) — não prova a fonte real --tier fake
driver_missing O driver do extra não está instalado onde a matriz correu —
stub Conector sem implementação, só no roadmap (hoje nenhum) —
unverified Correu contra uma fonte real mas nenhuma tentativa passou — o tier e a fonte ficam na coluna «Fonte», a classificação nunca diz real_* sem prova —
untested Driver presente mas sem fonte nos tiers corridos —

Um passo que falha por rede ou timeout fica 🌐 inacessível (não ❌): uma fonte pública instável não conta como regressão. O gate do CI (--fail-on-regression) compara com a baseline docs/verificacao/connector-matrix.json e falha quando uma linha real_* que passava volta com fail — e, numa fonte real que nunca passou por inteiro, quando um passo que estava ✅ volta ❌. Mensagens de erro passam por redact(): nenhum valor de DATAVAULT_MATRIX_*, password, token ou chave privada chega ao JSON, ao Markdown ou aos logs (testado em tests/test_connector_matrix.py).

cd backend
python scripts/connector_matrix.py --tier public,fake --write-docs   # sem Docker nem segredos
docker compose -f ../docker-compose.connectors.yml up -d --wait && python ../seed/connectors/seed.py
DATAVAULT_ALLOW_PRIVATE_NETWORKS=true python scripts/connector_matrix.py --tier docker --write-docs

--write-docs reescreve só a tabela entre os marcadores abaixo e junta os tiers corridos à baseline (os outros tiers ficam como estavam). Um conector novo tem de aparecer aqui — o teste da matriz falha se um tipo do CONNECTOR_REGISTRY não tiver linha.

Notas honestas (2026-09-25):

  • Imagem de produção sem Kafka, Pub/Sub e Fabric. O CONNECTOR_EXTRAS do build (cd.yml, gcp_deploy.sh, security.yml — iguais, testado) não inclui kafka, pubsub nem fabric (pyodbc + msodbcsql18): em produção estes tipos aparecem no wizard como «driver não incluído nesta instalação». Incluí-los é uma decisão à parte.
  • Pub/Sub não usa o emulador. O conector fala só com os endpoints Google fixos (anti-SSRF) e não aceita PUBSUB_EMULATOR_HOST; relaxar a guarda para um teste passar está fora de questão. A linha fica fake_transport + real_sandbox (quando há secrets). O job docker valida por isso 9 tipos em real_docker (postgres, mysql, mssql/SQL Server 2022, oracle/Oracle 23ai Free, mongodb/MongoDB 7, s3/MinIO, adls/Azurite, sftp/atmoz/sftp, kafka/Redpanda) — o GCS tem o mesmo desenho de endpoint fixo do Pub/Sub e fica de fora pela mesma razão.
  • SQL Server (mssql): real_docker só no job da matriz. Não existe endpoint SQL Server público; a prova real é o SQL Server 2022 do docker-compose.connectors.yml (seed/connectors/mssql.sql, login só com db_datareader), corrida no job docker (--expect-min real_docker:9 + tests/test_mssql_docker.py, que prova que o INSERT falha no servidor). Prova de 2026-09-25: run 36093070996 (commit c2a8c4c = código do 1296f65 + workflow restrito ao serviço mssql, porque a imagem minio/minio deixou de ser pública no Docker Hub), com os 14 testes de tests/test_mssql_docker.py verdes nos modos url e campos, incluindo o INSERT recusado pelo servidor ao login db_datareader. O modo Azure SQL/Synapse com Entra ID fica fake_transport (montagem do odbc_connect verificada sobre transporte falso) até haver secrets DATAVAULT_MATRIX_MSSQL_* de uma sandbox Azure.
  • Oracle (oracle): real_docker só no job da matriz, ainda sem run. Não há Oracle público. A prova real é o Oracle 23ai Free (gvenzl/oracle-free:23-slim) do docker-compose.connectors.yml, com seed/connectors/oracle.sql e o utilizador datavault_ro só com CREATE SESSION + SELECT. Corre no job docker (--expect-min real_docker:9 + tests/test_oracle_docker.py): ligação, descoberta sem schemas de sistema, tipos, perfil com CLOB e BLOB, linhas, estimativa do catálogo e o INSERT recusado com ORA-01456 numa tabela onde o GRANT o deixaria passar. O conector entrou sem essa corrida, porque foi feito numa máquina sem Docker: a linha fica driver_missing até à 1.ª corrida verde do job, que fica para um cartão à parte.
  • Azure Blob/ADLS (adls): Azurite fora do compose na prova de 2026-10-09. A tentativa real_docker da baseline correu contra o Azurite 3.35.0 instalado pelo npm (a versão da imagem do compose), porque na máquina do cartão não havia Docker; a fonte da tentativa di-lo. O job docker corre o mesmo seed contra a imagem mcr.microsoft.com/azure-storage/azurite:3.35.0, mais tests/test_adls_docker.py. A tentativa real_public lê o Parquet dos feriados do Azure Open Datasets com acesso anónimo.
  • SFTP (sftp): OpenSSH local fora do compose na prova de 2026-10-09. A tentativa real_docker da baseline correu contra o OpenSSH 10.2 do macOS (internal-sftp -R, chave Ed25519), porque na máquina do cartão não havia Docker; a fonte da tentativa di-lo. O job docker corre tests/test_sftp_docker.py e a matriz contra o serviço sftp do compose (atmoz/sftp, utilizador em chroot, chave de host gerada a cada run e fixada pelo fingerprint). A tentativa real_public liga ao servidor público de testes da Rebex (test.rebex.net, utilizador demo, só leitura), que só tem um readme.txt: prova a chave fixada, a listagem e a leitura de um documento, sem tabelas.
  • SAP (sap): a prova real é só o sandbox com API key. A fonte real do sap na matriz é o sandbox do SAP Business Accelerator Hub (o API_BUSINESS_PARTNER do S/4HANA Cloud), com o secret DATAVAULT_MATRIX_SAP_APIKEY; sem ele, a linha fica fake_transport. O sandbox usa o modo api_key, e por isso os modos basic e oauth2_client_credentials não têm prova contra um SAP real na matriz. O odata corre no tier public contra os serviços de referência do OData.org (v2 e v4).
  • MongoDB (mongodb): real_docker no job da matriz, real_sandbox opcional. Não há um MongoDB público para a matriz. A prova real é o mongo:7 do docker-compose.connectors.yml (seed/connectors/mongodb/, com tipos mistos, campos em falta, um order_id duplicado, datas e documentos aninhados, e um utilizador só com o papel read), corrida no job docker com tests/test_mongodb_docker.py. Esse teste prova também que o servidor recusa o insert a esse utilizador. Com os secrets DATAVAULT_MATRIX_MONGODB_URI e DATAVAULT_MATRIX_MONGODB_DATABASE de um cluster Atlas M0 (gratuito), o tier sandbox corre também contra o Atlas. A tentativa real_docker da baseline de 2026-10-09 correu contra o binário oficial mongod 7.0.14 com o mesmo seed, fora do compose, porque na máquina do cartão não havia Docker; a fonte da tentativa di-lo.
  • RNAcentral (postgres público): catálogo sim, dados não. Desde 2026-09-25 o utilizador público reader não tem USAGE em nenhum schema (permission denied for schema rnacen) e o information_schema.columns vem vazio. O conector passou a ler as colunas por pg_attribute + format_type, que nunca toca em pg_collation, e por isso test + discover + columns passam: é isso que o validate_prod.sh verifica. profile e rows falham do lado da EBI. A linha fica unverified no tier public e a prova completa do postgres é a do job docker (real_docker). O gate vigia os passos que hoje passam, e se columns voltar a ❌ o job falha. Avaliámos o Chado público da FlyBase como alternativa, mas está atrás de um pooler que recusa o parâmetro de arranque default_transaction_read_only, e a guarda read-only não se relaxa para um teste passar. Suportar poolers (PgBouncer/Supavisor) sem perder o read-only mecânico é trabalho à parte.
Conector Classificação Estado Fonte test discover columns profile rows Data Commit Driver Extra na imagem prod Run
adls real_public (+ real_docker) pass Feriados públicos — Azure Open Datasets (ADLS público) ✅ ✅ ✅ ✅ ✅ 2026-10-09 1eb40a1 2026.8.0 sim —
bigquery driver_missing driver_missing — · · · · · — — — sim —
confluence fake_transport pass fake (tests/test_confluence.py) (documentos) ✅ ✅ — — ✅ 2026-09-25 e9fc57f 0.28.1 sim —
csv real_public pass fixture seed/connectors/csv/ ✅ ✅ ✅ ✅ ✅ 2026-09-25 1ce0273 — sim —
databricks driver_missing driver_missing — · · · · · — — — sim —
dynamics fake_transport pass fake (sessão Dataverse falsa) ✅ ✅ ✅ ✅ ✅ 2026-09-25 e9fc57f — sim —
fabric driver_missing driver_missing — · · · · · — — — não —
gcs driver_missing driver_missing — · · · · · — — — sim —
gdrive fake_transport pass fake (tests/test_gdrive.py) (documentos) ✅ ✅ — — ✅ 2026-09-25 e9fc57f — não —
gsheets fake_transport pass fake (tests/test_gsheets_connector.py (folha pública «Class Data»)) ✅ ✅ ✅ ✅ ✅ 2026-10-09 5e325ca — não —
jira fake_transport pass fake (API Jira falsa) (documentos) ✅ ✅ — — ✅ 2026-09-25 e9fc57f 0.28.1 sim —
kafka fake_transport pass fake (tests/test_kafka_connector.py) ✅ ✅ ✅ ✅ ✅ 2026-09-25 e9fc57f — não —
mongodb real_docker (+ fake_transport) pass mongod 7.0.14 (binário oficial), local, com o seed do compose ✅ ✅ ✅ ✅ ✅ 2026-10-09 f153a84 4.18.3 sim —
mssql real_docker (+ fake_transport) pass SQL Server 2022 (compose) ✅ ✅ ✅ ✅ ✅ 2026-09-25 c2a8c4c 2.4.1 sim run
mysql real_public pass Rfam (EMBL-EBI) ✅ ✅ ✅ ✅ ✅ 2026-09-25 1ce0273 1.2.0 sim —
odata real_public (+ fake_transport) pass https://services.odata.org/V2/Northwind/Northwind.svc ✅ ✅ ✅ ✅ ✅ 2026-10-09 d181ba9 0.28.1 sim —
oracle driver_missing driver_missing — · · · · · — — — sim —
postgres unverified fail RNAcentral (EMBL-EBI) [public] ✅ ✅ ✅ ❌ · 2026-09-25 1ce0273 2.9.12 sim —
pubsub fake_transport pass fake (tests/test_pubsub_connector.py) ✅ ✅ ✅ ✅ ✅ 2026-09-25 e9fc57f — não —
rest_api fake_transport pass fake (tests/test_rest_api_connector.py) ✅ ✅ ✅ ✅ ✅ 2026-10-10 ef56027 0.28.1 sim —
s3 real_public pass OpenAQ — qualidade do ar (S3 público) ✅ ✅ ✅ ✅ ✅ 2026-09-25 1ce0273 2026.7.0 sim —
salesforce fake_transport pass fake (cliente simple_salesforce falso) ✅ ✅ ✅ ✅ ✅ 2026-09-25 e9fc57f — sim —
sap fake_transport pass fake (tests/test_odata_connector.py, Gateway v2) ✅ ✅ ✅ ✅ ✅ 2026-10-09 d181ba9 0.28.1 sim —
sftp real_public (+ real_docker) pass test.rebex.net (SFTP público da Rebex) (documentos) ✅ ✅ — — ✅ 2026-10-09 bc1c8a0 5.0.0 sim —
sharepoint fake_transport pass fake (tests/test_sharepoint.py) (documentos) ✅ ✅ — — ✅ 2026-09-25 e9fc57f — não —
slack fake_transport pass fake (tests/test_chat_signals.py) (documentos) ✅ ✅ — — ✅ 2026-09-25 e9fc57f 0.28.1 sim —
snowflake driver_missing driver_missing — · · · · · — — — sim —
sqlite real_public pass fixture seed/connectors/matrix.sql ✅ ✅ ✅ ✅ ✅ 2026-09-25 1ce0273 2.0.51 sim —
teams fake_transport pass fake (tests/test_chat_signals.py) (documentos) ✅ ✅ — — ✅ 2026-09-25 e9fc57f — não —

Legenda: ✅ ok · ❌ falhou · 🌐 fonte inacessível (rede/timeout — não conta como regressão) · — não se aplica · · não corrido.


BigQuery

O que é preciso: uma service account do Google Cloud com leitura no projeto/dataset e a respetiva chave JSON.

Passo a passo:

  1. Na consola Google Cloud, seleciona o projeto → IAM & Admin → Service Accounts → Create service account (ex.: datavault-reader).
  2. Atribui os papéis mínimos:
    • BigQuery Job User (roles/bigquery.jobUser) ao nível do projeto — necessário para executar queries de profiling;
    • BigQuery Data Viewer (roles/bigquery.dataViewer) — pode ser dado só ao(s) dataset(s) a analisar (Dataset → Sharing → Permissions) em vez do projeto inteiro, para privilégio mínimo.
  3. Na service account: Keys → Add key → Create new key → JSON. Descarrega o ficheiro.
  4. No wizard: preenche project (ID do projeto, ex.: praxis-analytics), opcionalmente dataset, e cola o conteúdo completo do ficheiro JSON no campo credentials_json (incluindo as chavetas). credentials_path (caminho de um ficheiro no servidor) só é aceite quando os conectores de ficheiro estão ativos (DATAVAULT_ENABLE_FILE_CONNECTORS, desligado em produção).

Credenciais sempre explícitas (varredura 2026-09, A2): sem credentials_json a conexão é recusada — o BeryX nunca usa as credenciais do próprio servidor (ADC/service account do Cloud Run). O mesmo vale para GCS (service_account_info com a chave JSON, ou anonymous: true para buckets públicos) e S3 (access_key_id + secret_access_key, ou anonymous: true).

Wizard guiado (GRAPH-ONBOARD): para BigQuery e GCS (com credenciais) o passo 2 da nova conexão mostra o Guia de registo Google Cloud (API ativa → service account → papéis de leitura → chave JSON → campos) e o botão Testar acesso (POST /connections/check, config não guardado). Aqui o teste é um só passo (connection, o test_connection do conector); a UI liga a mensagem ao passo provável do guia (API desativada, 403/permissões, JSON/invalid_grant, recurso não encontrado).

Instalação: pip install "datavault-backend[bigquery]"

Limitações conhecidas:

  • O profiling executa queries no BigQuery — há custo por bytes lidos, limitado pelos tetos da secção seguinte.
  • Nomes de datasets são sensíveis a maiúsculas; o dataset tem de estar na região esperada pelo projeto.
  • Se credentials_json e credentials_path forem ambos preenchidos, o JSON colado tem precedência.
  • A chave tem de ser de uma service account ("type": "service_account"); outros tipos de credencial (ex.: external_account) são recusados.
  • O teste de ligação valida autenticação, projeto e existência do dataset (quando indicado), com mensagens distintas para cada caso.
  • Os tetos de custo são campos da config, definidos ao criar a conexão. Para os mudar numa conexão que já existe, recria-a ou ajusta os valores da instalação (DATAVAULT_BIGQUERY_*, em CONFIGURACAO.md), que valem para todas as conexões sem valor próprio.

Custo de um scan, tetos e partições

O BigQuery on-demand fatura os bytes das colunas que cada query lê, e o LIMIT não os reduz. Sem tetos, um scan a uma tabela de 2 TB com 100 colunas podia faturar dezenas de TB. Desde o cartão #293, o BeryX limita o custo antes de ler e não só depois (a telemetria de custo, COSTX, mede o que já foi faturado).

Modelo de custo por tabela. O scan conta por passagens. Cada passagem lê, no pior caso, a tabela inteira, com o tamanho de __TABLES__.size_bytes (bytes lógicos, a base da fatura on-demand). É um majorante, porque cada query lê só as suas colunas.

Passagem O que corre Bytes (majorante)
Agregados COUNT/MIN/MAX/AVG/STDDEV_POP por lotes de 10 colunas (o COUNT(DISTINCT) vai na mesma query) 1×
Amostras por coluna SELECT DISTINCT col … LIMIT 20, uma por coluna ≈1×, somadas
Quantis APPROX_QUANTILES das colunas numéricas (profundidade standard/deep) ≤1×
Histogramas SUM(CASE …) das colunas numéricas (standard/deep) ≤1×
Linhas SELECT … LIMIT 5000 para as regras por linha e os duplicados 1×

O COUNT(*) não lê colunas e o COUNT(DISTINCT) não acrescenta bytes (gasta slots, o que pesa em capacity pricing). Correlações e co-nulos (deep), segmentos, a regra unique e as regras SQL são uma query cada. Ficam fora da estimativa, mas sujeitas ao teto por query.

Metadados. Por tabela e scan, o BeryX lê __TABLES__ e três vistas de INFORMATION_SCHEMA (COLUMNS, TABLE_OPTIONS, PARTITIONS). Segundo a documentação da Google, cada consulta a INFORMATION_SCHEMA fatura no mínimo 10 MB on-demand. A leitura fica em cache durante o scan, e os monitores automáticos reutilizam-na.

Tetos (campos da config da conexão; sem valor próprio, valem os da instalação):

Campo Omissão O que faz
max_bytes_per_query 50 GB (DATAVAULT_BIGQUERY_MAX_BYTES_PER_QUERY) Vai em cada job como maximum_bytes_billed. O BigQuery estima os bytes antes de correr e recusa a query sem a faturar. O mínimo é 10 MiB (10 485 760 bytes), o mínimo que o BigQuery fatura. Se o url já trouxer maximum_bytes_billed, fica o mais baixo dos dois. Numa tabela com clustering a estimativa do BigQuery é um majorante, e uma query pode ser recusada mesmo que os bytes reais coubessem.
max_bytes_per_scan 200 GB (DATAVAULT_BIGQUERY_MAX_BYTES_PER_SCAN) Teto da estimativa por tabela num scan (passagens × tamanho).
partition_window_days 7 (DATAVAULT_BIGQUERY_PARTITION_WINDOW_DAYS) Dias de partições perfilados quando é preciso uma janela.

Degradação por ordem. Acima do teto por scan, o perfil degrada, por esta ordem:

  1. Salta o COUNT(DISTINCT). Não poupa bytes, mas poupa slots.
  2. Troca as amostras por coluna e as linhas cruas por uma query cada, SELECT … TABLESAMPLE SYSTEM (p PERCENT), com p calculado para ler ~1 GB.
  3. Salta os histogramas.

O passo 2 também se aplica quando uma passagem completa já passa o teto por query, porque um SELECT * seria recusado. Se nem assim couber, uma tabela particionada por data passa à janela de partições. Uma tabela sem partições temporais é recusada antes de qualquer query aos dados, com a mensagem dos tetos. O que se decidiu fica em metrics_json.cost_guard do perfil da tabela: os tetos, scanned_bytes, estimated_bytes, planned_bytes, degraded e sample_percent. O log regista scan_cost_guarded.

Partições. O BeryX lê INFORMATION_SCHEMA.COLUMNS.is_partitioning_column, TABLE_OPTIONS.require_partition_filter e PARTITIONS (linhas, bytes lógicos e última escrita por partição). Perfila só as partições dos últimos partition_window_days dias (hoje incluído) em dois casos:

  • quando a tabela exige filtro de partição (require_partition_filter = true, prática comum em contas enterprise; sem isto, o primeiro COUNT(*) falhava e o dataset ficava failed sem explicação);
  • quando a tabela inteira passa um dos tetos e está particionada por data.

A partição pode ser por coluna DATE/TIMESTAMP/DATETIME ou por hora de ingestão, com a pseudocoluna _PARTITIONTIME. O filtro é coluna >= <início da janela>, com uma constante, para o BigQuery podar as partições. A janela vale para o perfil e para as passagens seguintes do scan: distribuições, segmentos, a regra unique (que passa a dizer que não contou a tabela inteira) e as linhas.

As regras SQL (expression, custom_sql) correm o SQL da regra tal como está. Numa tabela com filtro obrigatório, o filtro de partição tem de estar na própria regra. Sem ele, ou acima do teto por query, a regra fica com o erro em PT a dizer o que mudar na regra ou na conexão, e o scan continua. Fora de um scan, uma leitura avulsa de linhas de uma tabela acima do teto por scan não é recusada: sai por TABLESAMPLE.

O perfil por janela segue o contrato dos perfis amostrados (#290): o row_count é o da tabela inteira (soma de PARTITIONS, ou __TABLES__), e as métricas das colunas são as da janela (sampled, sample_rows). Assim, o volume e o drift continuam comparáveis entre scans. metrics_json.cost_guard.window guarda column, days, since, reason (require_partition_filter, max_bytes_per_query ou max_bytes_per_scan), o número de partições e as linhas da janela. Uma tabela particionada por intervalo de inteiros com filtro obrigatório não tem janela temporal, e o perfil é recusado com uma mensagem a explicar porquê.

Monitores automáticos. O table_metadata passa a usar INFORMATION_SCHEMA.PARTITIONS: row_count é a soma das partições e last_modified a última escrita. Se a vista não for legível, usa __TABLES__. Segundo a documentação da Google, a vista PARTITIONS ainda está em pré-GA.

Erros acionáveis. «Query exceeded limit for bytes billed» passa a «A tabela 'x' excede o teto de 50 GB por query desta conexão (maximum_bytes_billed): o BigQuery recusou a query sem a faturar», seguido do que fazer (subir max_bytes_per_query, reduzir a janela ou particionar a tabela por data). «Cannot query over table … partition elimination» passa a uma mensagem sobre as permissões de metadados. O dataset fica failed, com a razão em PT no teatro do scan e no resumo do scan (stats.dataset_errors). Uma recusa por teto nunca é engolida pelos fallbacks do perfil, porque daria um perfil «concluído» sem métricas.

Permissões. COLUMNS, TABLE_OPTIONS e PARTITIONS exigem bigquery.tables.get e bigquery.tables.list, já incluídas em BigQuery Data Viewer. Sem elas, o perfil segue sem janela nem contas por partição. Numa tabela com filtro obrigatório, a mensagem diz que permissões faltam.

Mudança de comportamento (upgrade). Uma tabela que, numa só query, lia mais de 50 GB passa a ser perfilada por janela (se estiver particionada por data) ou falha com a mensagem do teto. Uma tabela cuja estimativa passe os 200 GB degrada. Para manter o comportamento anterior, sobe os tetos na instalação ou na conexão.

Databricks

O que é preciso: um SQL Warehouse ativo, um Personal Access Token (PAT) e o HTTP path do warehouse.

Passo a passo:

  1. PAT: no workspace, canto superior direito → User Settings → Developer → Access tokens → Generate new token. Copia o token (só é mostrado uma vez). Usa de preferência um utilizador/service principal com acesso apenas de leitura aos catálogos a analisar.
  2. HTTP path: SQL Warehouses → (o teu warehouse) → Connection details → HTTP path (formato /sql/1.0/warehouses/<id>).
  3. Host: no mesmo ecrã, o Server hostname (ex.: adb-1234567890123456.7.azuredatabricks.net ou dbc-abc12345-de67.cloud.databricks.com). Podes colar com ou sem https:// — é normalizado.
  4. No wizard: host, access_token, http_path; opcionalmente catalog e schema (Unity Catalog) para limitar a descoberta.

Instalação: pip install "datavault-backend[databricks]"

Limitações conhecidas:

  • O warehouse tem de estar a correr: um warehouse parado com auto-stop pode fazer o primeiro teste falhar por timeout — volta a tentar após 1-2 minutos.
  • Permissões Unity Catalog necessárias: USE CATALOG, USE SCHEMA e SELECT nas tabelas.
  • PATs expiram conforme a política do workspace — renova-os e atualiza a conexão.
  • O teste de ligação distingue na mensagem: token inválido vs host errado vs http_path errado.

Execução no vosso Databricks (opcional, ADR 0005 degrau B)

Para medir duplicados de linha inteira, checks e Cramér's V na tabela completa sem trazer linhas para fora do workspace, o BeryX pode submeter o pacote público beryx-profiler como one-time run na vossa conta. O job lê as tabelas e devolve só métricas agregadas por HTTPS à API do BeryX.

Estado: ainda não corre nos scans. O pacote, o cliente da Jobs API e a ingestão existem no código, mas nenhum scan submete o job ainda; até lá, também no Databricks as regras de linha e os duplicados usam a amostra descrita em Segurança. O resto desta secção descreve como funciona.

  • Pegada declarada: um run efémero (runs/submit, não cria Job persistente) com o nome beryx-profiler <id>, visível no histórico. Nada é carregado para DBFS, Volumes ou ficheiros do workspace; o código vem do PyPI, e o pip só instala o wheel com a versão e o SHA-256 que fixámos. Sem esse hash fixado, o run não é submetido. O job nunca escreve dados.
  • Credencial dedicada (config remote_jobs, cifrada com Fernet, nunca devolvida pela API): token de um service principal — não o PAT do warehouse.
    "remote_jobs": {
      "token": "<token do SP>",
      "compute": {"mode": "serverless"}
    }
    
    compute.mode: existing_cluster (cluster_id), policy (policy_id, spark_version, node_type_id, num_workers?) ou serverless. host e catalog são opcionais (por omissão, os da conexão).
  • Permissões mínimas do SP: USE CATALOG, USE SCHEMA e SELECT nas tabelas; no compute, CAN_ATTACH_TO no cluster ou CAN_USE na cluster policy ou serverless jobs ativo. Nada de CAN_MANAGE, admin do workspace nem escrita em catálogos.
  • Rede: saída HTTPS do workspace para a API do BeryX (DATAVAULT_API_PUBLIC_BASE_URL).
  • Parâmetros do run: visíveis a quem vê o run no workspace (página do run e runs/get). Levam a spec, o URL de ingestão e um código de levantamento dvw_* de uso único, nunca o token que entrega as métricas. O job gera esse token e regista-o com o código antes de ler tabelas. Se outra pessoa usar o código, o run falha e as métricas são descartadas.
  • Custo: DBUs na vossa conta; cada run tem timeout_seconds (por omissão 30 min) e é cancelado (runs/cancel) se o exceder.
  • Chamadas à Jobs API (allowlist fechada no cliente): POST runs/submit, POST runs/cancel, GET runs/get, GET runs/get-output, GET scim/v2/Me. Qualquer outra é bloqueada antes de sair do BeryX.

Snowflake

O que é preciso: um utilizador read-only dedicado, com role e warehouse próprios.

Passo a passo (executar como ACCOUNTADMIN/SECURITYADMIN):

CREATE ROLE IF NOT EXISTS DATAVAULT_READER;
GRANT USAGE ON WAREHOUSE <wh> TO ROLE DATAVAULT_READER;
GRANT USAGE ON DATABASE <db> TO ROLE DATAVAULT_READER;
GRANT USAGE ON ALL SCHEMAS IN DATABASE <db> TO ROLE DATAVAULT_READER;
GRANT SELECT ON ALL TABLES IN DATABASE <db> TO ROLE DATAVAULT_READER;
GRANT SELECT ON FUTURE TABLES IN DATABASE <db> TO ROLE DATAVAULT_READER;
CREATE USER IF NOT EXISTS datavault_svc PASSWORD='<forte>' DEFAULT_ROLE=DATAVAULT_READER;
GRANT ROLE DATAVAULT_READER TO USER datavault_svc;

O account é o account identifier (ex.: xy12345.eu-west-1 ou org-conta), visível em Admin → Accounts ou no URL do Snowsight.

Campos do wizard: account, user, password, database; opcionais schema, warehouse, role.

Instalação: pip install "datavault-backend[snowflake]"

Limitações conhecidas: o warehouse consome créditos durante o profiling (usa um XS dedicado com auto-suspend curto); sem role, é usada a default do utilizador.

Dynamics 365 (Dataverse)

O que é preciso: uma app registration no Entra ID com um segredo, e um application user no ambiente Dataverse com perfil de leitura.

Passo a passo:

  1. App registration: entra.microsoft.com → Identity → Applications → App registrations → New registration (ex.: datavault-reader). Anota o Application (client) ID (client_id) e o Directory (tenant) ID (tenant_id) da página Overview.
  2. Segredo: na app → Certificates & secrets → New client secret. Copia o valor (client_secret) — só é mostrado uma vez.
  3. Permissão API (para consentimento): API permissions → Add a permission → Dynamics CRM → Delegated → user_impersonation → Grant admin consent. Nota: para o fluxo server-to-server usado pelo BeryX, o que dá efetivamente acesso é o passo seguinte.
  4. Application user no Dataverse (essencial): admin.powerplatform.microsoft.com → Environments → (o teu ambiente) → Settings → Users + permissions → Application users → + New app user → escolhe a app registration → atribui um security role com leitura nas entidades a analisar (ex.: um role personalizado read-only, ou Basic User + read nas tabelas relevantes).
  5. URL da organização (org_url): o endereço do ambiente, ex.: https://a-tua-org.crm4.dynamics.com (visível no admin center ou no browser). Pode ser colado com ou sem https://.

Campos do wizard: org_url, tenant_id, client_id, client_secret; opcional entities — lista (ou string separada por vírgulas) de nomes de conjunto (plural, ex.: accounts,contacts,leads,opportunities,incidents). Default: accounts, contacts, leads, opportunities.

Instalação: pip install "datavault-backend[dynamics]"

Limitações conhecidas:

  • O row_count usa $count=true do OData, que o Dataverse limita a 5000 — contagens acima disso aparecem truncadas.
  • O profiling é feito sobre uma amostra (primeiras ~1000 linhas via $top), não sobre a tabela inteira; percentagens de nulos e distintos são relativas à amostra.
  • A Web API tem throttling (limites de pedidos por utilizador); scans de muitas entidades podem abrandar.
  • Cada resposta da Web API tem um teto de 30 MB, e o timeout de 30 s conta para o pedido inteiro. As linhas dos scans chegam em páginas de 1000 registos. Uma resposta acima do teto falha com «Resposta Dataverse demasiado grande».
  • O teste de ligação faz token + WhoAmI e distingue: segredo inválido vs client_id/tenant_id errados vs falta de application user no ambiente (403).
  • A mensagem do teste de ligação vai para o último erro da conexão, que todos os membros do workspace veem. Desde o cartão #610, passa pelo mesmo filtro dos conectores SQL: o valor do client_secret e a password de um org_url com user:password@ saem como ***. Prova: backend/tests/test_dynamics_connection_redaction.py.

Confluence / Jira (Atlassian Cloud, fonte documental)

Duas fontes documentais sobre a mesma conta Atlassian Cloud (email + API token), as mesmas credenciais da integração de tickets Jira em Notificações. Não há tabelas nem scans tabulares: o conteúdo entra pelo botão Documentos (ou POST /connections/{id}/documents).

Credenciais — do lado do cliente:

  1. Uma conta Atlassian dedicada (ex.: datavault@empresa.pt) só com leitura: permissão View nos espaços Confluence a analisar e Browse projects nos projetos Jira a ler.
  2. Com essa conta, em https://id.atlassian.com/manage-profile/security/api-tokens → Create API token. Copia o token (ATATT…), que só é mostrado uma vez. O conector autentica por Basic auth (email + token) no endereço do site.
  3. No wizard: site (ex.: a-tua-org.atlassian.net, com ou sem https://), o email da conta, o api_token e, opcionalmente, o espaço (Confluence, space_key) ou o projeto (Jira, project_key). Pela API, o Jira aceita também jql.

Confluence — como funciona: lista as páginas current do espaço (sem space_key, todas as páginas que a conta vê), 50 por pedido, até ao limit da análise (200 por omissão, máximo 1000). Cada página é um documento confluence/<id>/<título>.html com o corpo em storage format (HTML) e a data da última versão. Teto de 5 MB por página. Sinais: desligados por omissão, porque a documentação interna não vai ao LLM sem ninguém pedir. mine_signals: true na configuração liga-os.

Jira — como funciona: pesquisa por GET /rest/api/3/search/jql (o /search legado responde 410). A JQL efetiva é project = <project_key> combinada com o jql da configuração, ordenada por updated DESC (ou pelo ORDER BY do jql). Sem projeto nem JQL, usa updated >= -30d. No máximo 500 issues por análise, paginadas por nextPageToken. Cada issue é um documento jira/<CHAVE>/<resumo>.txt com resumo, estado, labels, descrição (ADF → texto) e os 30 comentários mais recentes, estes com o nome de quem comentou. Teto de 2 MB por issue (o texto é cortado). Sinais: ligados por omissão, porque é para isso que a fonte existe. Cada issue passa pelo pipeline SIGNALS (pré-filtro por glossário DQ → extração → ligação ao catálogo → upsert idempotente por issue) e o sinal liga a https://<site>/browse/<CHAVE>. mine_signals: false desliga-os.

Diagnóstico do teste de ligação: o Confluence lê GET /wiki/rest/api/space?limit=1 e o Jira GET /rest/api/3/myself (a mensagem diz com que conta autenticou). 401 = email ou token recusados (no Jira também 403, que inclui a falta de permissões), 404 = site, espaço ou JQL errados. Uma resposta que não é JSON (página de SSO, proxy ou manutenção) tem mensagem própria. No Jira, o 429 (rate limit) e o 410 (endpoint descontinuado) também.

Segurança: só GETs, pelo ReadOnlyHttpClient (anti-SSRF + pinning de DNS), ao site indicado, que também passa pela guarda na criação da conexão. A chave de projeto é validada (^[A-Z][A-Z0-9_]{0,9}$) antes de entrar na JQL. Os ids de página e as chaves de issue são validados antes de entrarem no URL.

Instalação: nenhuma — os dois conectores só usam httpx, que é dependência base. Vão na imagem de produção.

Limitações conhecidas:

  • Só Atlassian Cloud: os caminhos são os da Cloud (/wiki/rest/api, /rest/api/3), e Server/Data Center ficam fora.
  • No Confluence, só páginas: blog posts, anexos e comentários não são lidos.
  • No Jira, 500 issues e 30 comentários por issue em cada análise. Um 429 do Jira não é repetido: na listagem falha a análise, na leitura de uma issue salta essa issue.
  • Estado de validação: só fake_transport (ver a matriz acima). Ainda não há prova contra uma instância Atlassian real.

SharePoint / Teams (Microsoft Graph, fonte documental)

Decisão de registo: ADR 0003, opção A — a app é registada pelo cliente no seu tenant, com o menor privilégio possível (Sites.Selected). Os ficheiros de um canal do Teams vivem no site SharePoint da equipa: um site de equipa conta como site. Mensagens do Teams estão fora de âmbito.

O que é preciso: alguém com o papel Cloud Application Administrator (ou Privileged Role Administrator) para o consentimento e um SharePoint Administrator (ou dono do site) para a concessão por site.

Passo a passo:

  1. Entra ID → App registrations → New registration (single tenant, sem redirect URI). Anota o Application (client) ID e o Directory (tenant) ID.
  2. API permissions → Microsoft Graph → Application → Sites.Selected → Grant admin consent. Não uses Sites.Read.All nem Files.Read.All.
  3. Certificates & secrets → New client secret (validade ≤ 12 meses). Certificado: cartão GRAPH-CERT.
  4. Conceder read à app em cada site pretendido: Graph POST /sites/{site-id}/permissions com roles: ["read"] (Graph Explorer) ou Grant-PnPAzureADAppSitePermission -Permissions Read.
  5. No wizard: URLs dos sites (um por linha, ex.: https://a-tua-org.sharepoint.com/sites/Qualidade), tenant_id, client_id, client_secret e, opcionalmente, uma pasta dentro de cada biblioteca (folder). Pela API também drive_names (lista de bibliotecas a incluir).

Instalação: pip install "datavault-backend[sharepoint]" (msal, a mesma biblioteca do Dynamics).

Como funciona: fonte documental — não há tabelas nem scans tabulares. O botão Documentos percorre as bibliotecas de cada site (subpastas até 5 níveis, paginação @odata.nextLink) e cada ficheiro PDF/DOCX/TXT/MD/HTML entra na análise de documentos (UNSTR). Teto de 20 MB por ficheiro, verificado antes do download.

Segurança: só GETs pelo ReadOnlyHttpClient (guard anti-SSRF + pinning de DNS); cada @odata.nextLink tem de apontar para https://graph.microsoft.com/v1.0/ (lição A1); o download segue o 302 para *.sharepoint.com sem o header Authorization. A única saída fora do cliente é o POST do token que o msal faz a login.microsoftonline.com.

Diagnóstico do teste de ligação: segredo inválido/expirado, client_id ou tenant_id errados (códigos AADSTS), 403 = falta a concessão read no site (Sites.Selected), 404 = URL do site errado. Throttling (429/503) respeita Retry-After (até 2 tentativas).

Wizard guiado (GRAPH-ONBOARD): no passo 2 da nova conexão, o Guia de registo mostra os 5 passos acima com um «ecrã» textual do portal (menus em inglês), os campos exatos (com botão copiar) e os erros comuns de cada passo. O botão Testar acesso envia o config ainda não guardado para POST /connections/check e recebe um resultado por passo, na ordem do guia:

step O que prova Falha típica → passo do guia
fields campos completos, tenant_id e URLs dos sites bem formados (sem rede) 5
dependency msal instalado no servidor BeryX — (operação da BeryxTech)
register tenant e app registration reconhecidos (AADSTS90002 / AADSTS700016) 1
secret client secret aceite (AADSTS7000215 inválido, AADSTS7000222 expirado) 3
consent o token traz Sites.Selected no claim roles (lido sem validar assinatura, só para diagnóstico); sem roles = falta Grant admin consent; Sites.Read.All/Files.Read.All/… = aviso de privilégio excessivo 2
site_grant (um por site) GET /sites/{host}:/{path} + bibliotecas visíveis; 403 = falta a concessão read, 404 = URL errado 4

Um passo que depende de outro que falhou vem skipped. Avisos não bloqueiam. O token e o segredo nunca entram nas mensagens nem nos logs (o log onboarding_access_checked leva só workspace, tipo, ok e os step falhados).

Google Drive (Drive API v3, fonte documental)

Decisão de registo: ADR 0003, opção A — a credencial é do cliente, no projeto GCP dele, e o BeryX pede sempre só o scope https://www.googleapis.com/auth/drive.readonly. A base documental (limites, contrato, helpers) é a mesma do SharePoint: app/infra/connectors/documents.py.

Três modos de credencial (credentials_json), pela ordem recomendada:

  1. Service account + partilha (por omissão): chave JSON de uma SA sem papéis IAM; o cliente partilha as pastas/Shared Drives com o email da SA como Leitor. O alcance é exatamente o que foi partilhado.
  2. Service account + domain-wide delegation (só se a política de partilha do Workspace bloquear iam.gserviceaccount.com): a mesma chave + subject (email do utilizador técnico impersonado). No Admin Console → Security → API controls → Domain-wide delegation: o Unique ID numérico da SA com apenas drive.readonly. É mais largo (vê tudo o que o subject vê): usar um utilizador com acesso só às pastas certas.
  3. App OAuth do cliente: JSON "type": "authorized_user" (client_id, client_secret da app OAuth registada no projeto do cliente + refresh_token de um utilizador técnico). O refresh pede só drive.readonly; se esse scope não foi consentido, o Google recusa. App em modo Testing expira o refresh token em 7 dias — publicar como Internal.

Qualquer outro type (ex.: external_account) é recusado na criação: nunca ADC nem credenciais do servidor.

Passo a passo (modo 1):

  1. Projeto GCP do cliente → APIs & Services → Library → ativar a Google Drive API.
  2. IAM & Admin → Service Accounts → criar a SA sem papéis.
  3. Keys → Add key → JSON (ou, mais tarde, Workload Identity Federation — cartão GRAPH-CERT).
  4. No Drive: Share de cada pasta (ou membro Viewer do Shared Drive) com o email da SA.
  5. No wizard: JSON da credencial, pastas (um URL https://drive.google.com/drive/folders/<id> ou id por linha, até 20), shared_drive_id opcional, subpasta opcional (folder, caminho de nomes dentro de cada raiz) e, só no modo 2, o utilizador impersonado.

Instalação: pip install "datavault-backend[gdrive]" (google-auth + requests, o transporte oficial do refresh).

Como funciona: fonte documental — sem tabelas nem scans tabulares. O botão Documentos percorre cada raiz com files.list ('<pasta>' in parents and trashed = false, supportsAllDrives/includeItemsFromAllDrives, corpora=drive no Shared Drive), subpastas até 5 níveis, paginação por pageToken e teto de 500 pedidos. Ficheiros PDF/DOCX/TXT/MD/HTML entram por files.get?alt=media; Google Docs entram exportados em HTML e Google Slides em texto (files.export); Sheets, Forms e atalhos ficam de fora. Caminhos gdrive/<file-id>/<nome>. Teto de 20 MB por ficheiro, verificado antes do download quando o Drive declara o tamanho (e sempre sobre o corpo). O prefixo pedido na análise é uma subpasta dentro das raízes configuradas — o alcance nunca sai do que foi partilhado.

Segurança: só GETs pelo ReadOnlyHttpClient (guard anti-SSRF + pinning de DNS) para https://www.googleapis.com/drive/v3; a paginação é um parâmetro, nunca um URL devolvido. Ids de pasta/ficheiro validados (^[A-Za-z0-9_-]+$) antes de entrarem no URL ou na query q; nomes de subpasta escapados na q. O token_uri do JSON colado é ignorado e fixado em https://oauth2.googleapis.com/token (um JSON adulterado mandaria o JWT assinado ou o refresh token para outro host); universe_domain ≠ googleapis.com é recusado. A única saída fora da Drive API é esse POST do google-auth. Chave privada, refresh token e access token nunca entram em mensagens nem logs.

Wizard guiado e diagnóstico: o Guia de registo Google Drive (API → SA → chave → partilha → delegação opcional → campos) e o Testar acesso devolvem um resultado por passo:

step O que prova Falha típica
fields JSON válido (service_account/authorized_user), pastas/ids bem formados, subject só com SA (sem rede) JSON incompleto, URL que não é drive.google.com/…/folders/<id>
dependency google-auth instalado no servidor BeryX — (operação da BeryxTech)
key credencial aceite pelo Google com drive.readonly invalid_grant (chave apagada / refresh token revogado), invalid_client, invalid_scope
dwd (só com subject) delegação autorizada para o Client ID unauthorized_client, utilizador inexistente
api Google Drive API ativa no projeto accessNotConfigured / SERVICE_DISABLED
share (uma por pasta) a raiz é visível e é uma pasta/Shared Drive 404 = não partilhada com o email da SA (o Drive responde 404, não 403)

Throttling (429/503 e 403 userRateLimitExceeded/rateLimitExceeded) respeita Retry-After (até 2 tentativas, espera ≤ 10 s).

Google Sheets (Sheets API v4)

Cada separador de uma folha Google é um dataset com o nome «título da folha/separador» (cartão #213). A credencial é a do Google Drive, com os mesmos três modos: a chave JSON de uma service account com a folha partilhada como Leitor, a mesma chave com subject (domain-wide delegation) ou o JSON authorized_user de uma app OAuth do cliente. O token_uri do JSON é fixado e o universe_domain validado pelo mesmo código (GoogleApiMixin, em app/infra/connectors/gdrive.py). Implementação: app/infra/connectors/gsheets.py.

Config: credentials_json, spreadsheet_ids (ids ou URLs https://docs.google.com/spreadsheets/d/<id>/…, um por linha, até 20; ids ^[A-Za-z0-9_-]+$), header_row? (linha do cabeçalho, 1 por omissão, até 1000), sheets? (filtro de separadores pelo nome) e subject?. A criação devolve 422 (netguard.GoogleSheetsConfigError) a uma credencial que não seja service_account/authorized_user, a um universo que não seja googleapis.com, a um id ou URL mal formado e a um header_row fora de 1–1000.

Scopes: spreadsheets.readonly para a Sheets API. A data de modificação vem do Drive (files.get?fields=modifiedTime), que não aceita esse scope: esse pedido leva um token à parte com drive.metadata.readonly. Com domain-wide delegation, autoriza os dois scopes no Admin Console. Sem o segundo, ou sem a Drive API ativa, a leitura continua e a frescura fica por medir.

Passo a passo (service account):

  1. Projeto GCP do cliente → APIs & Services → Library → ativar a Google Sheets API e, para a frescura, a Google Drive API.
  2. IAM & Admin → Service Accounts → criar a SA sem papéis e a chave JSON (como no Drive).
  3. Na folha: Partilhar com o email da SA, papel Leitor.
  4. No wizard: JSON da credencial, folhas (um URL ou id por linha), linha do cabeçalho e, se quiseres, os separadores a ler.

Instalação: pip install "datavault-backend[gdrive]", o extra do Drive (google-auth + requests). Não há extra novo.

Como lê:

  • spreadsheets.get (só sheets.properties) lista os separadores. Só os de grelha (sheetType GRID) viram dataset: gráficos e ligações ao BigQuery não têm células. Se duas folhas tiverem o mesmo título, a segunda leva o início do id no nome.
  • values.get com valueRenderOption=UNFORMATTED_VALUE e dateTimeRenderOption=SERIAL_NUMBER, em blocos de 10 000 linhas sem passar o rowCount da grelha, até 100 000 linhas por separador (ROW_LIMIT; acima disso o perfil diz sampled). A leitura para no primeiro bloco inteiro vazio.
  • Colunas: o texto da linha de cabeçalho. Uma célula vazia dá à coluna a letra dela (C); um nome repetido ganha _2. Números e booleanos chegam tipados. Um spreadsheets.get com a máscara effectiveFormat.numberFormat.type sobre as primeiras 200 linhas diz que colunas são datas (DATE/DATE_TIME na maioria): nessas, o número de série passa a data no fuso da folha, e as células TIME passam a HH:MM:SS. A coerção e o perfil (nulos, distintos, mín./máx., média, amostras) são os do CSV (coerce_values e profile_columns), em memória: 00123 fica texto.
  • row_count conta as linhas não vazias abaixo do cabeçalho. As vazias ficam em source_metrics.blank_rows e as células com erro de fórmula (#N/A, #REF!…) em source_metrics.error_cells.
  • Um separador vazio, sem cabeçalho ou só com o cabeçalho continua a ser um dataset: o perfil traz o aviso em source_metrics.warnings e nada levanta exceção.
  • Frescura: a primeira coluna de data. Sem nenhuma, vale o modifiedTime da folha no Drive (por folha, não por separador), que é também o table_metadata.last_modified.
  • Cada scan tem 500 pedidos HTTP. Esgotado o orçamento, o separador seguinte falha com essa mensagem.

Segurança: só GETs pelo ReadOnlyHttpClient (anti-SSRF + pinning de DNS) a https://sheets.googleapis.com/v4/spreadsheets e https://www.googleapis.com/drive/v3/files. Os hosts estão fixos no código e nada do config entra neles. O nome do separador vai citado ('Sheet1'!1:10000) e codificado no caminho. O módulo não tem values.update, append nem batchUpdate: tests/test_gsheets_connector.py prova-o com um transporte falso que recusa POST/PUT.

Testar acesso: os passos do Google Drive, com a Sheets API:

step O que prova Falha típica
fields JSON válido, ids/URLs bem formados, header_row, subject só com SA (sem rede) URL que não é docs.google.com/spreadsheets/d/<id>
dependency google-auth instalado no servidor BeryX — (operação da BeryxTech)
key credencial aceite pelo Google com spreadsheets.readonly invalid_grant, invalid_client, invalid_scope
dwd (só com subject) delegação autorizada para o Client ID unauthorized_client, utilizador inexistente
api Google Sheets API ativa no projeto; aviso (warn) se a Google Drive API não estiver (fica sem frescura) SERVICE_DISABLED
share (uma por folha) a folha é visível 403 ou 404 = não partilhada com o email da SA

Throttling (429/503) respeita Retry-After, como no Drive.

Slack e Teams — canais de chat como fonte de sinais (CHATSIG, ADR 0008)

Extensão do SIGNALS: os fios de conversa (mensagem + respostas) em canais escolhidos pelo cliente entram no mesmo pipeline dos tickets Jira (pré-filtro por glossário DQ → extração Haiku ou heurística → ligação ao catálogo → upsert idempotente por source_ref com content_hash). Cada fio é um documento slack/<canal>-<ts>/… ou teams/<chave>-<mensagem>/…; o corpus aparece no catálogo como documentos/canais e os sinais na página Sinais. Corre com o botão Documentos (ou POST /connections/{id}/documents).

Privacidade — opt-in por canal, nunca DMs:

  • Só os canais listados em channels são lidos; sem lista, a conexão é recusada. Um caminho forjado para outro canal é recusado antes de qualquer pedido.
  • Slack: ids de mensagem direta (D…) são recusados no config; conversations.info confirma que cada canal não é DM nem conversa de grupo (is_im/is_mpim) antes de ler mensagens. Só bot tokens (xoxb-): um user token daria acesso às DMs de quem o gerou.
  • Teams: só canais de equipa (19:…@thread.tacv2); ids de chat (1:1, grupo, reunião) são recusados e nenhum endpoint /chats é chamado.
  • Autores nunca entram no texto enviado à IA (nem nomes, nem ids); menções a pessoas passam a @utilizador. Mensagens de bots/aplicações, de sistema e apagadas são ignoradas — os alertas do próprio BeryX num canal não viram «queixas».
  • Janela limitada: lookback_days (30 por omissão, máx. 90); até 20 canais por conexão e 100 respostas por fio. mine_signals: false desliga a mineração mantendo a conexão.

Slack — do lado do cliente:

  1. https://api.slack.com/apps → Create New App (from scratch) no workspace da organização.
  2. OAuth & Permissions → Bot Token Scopes: channels:history, channels:read e, só se houver canais privados a minerar, groups:history, groups:read. Nunca im:* nem mpim:* (o «testar acesso» avisa se existirem).
  3. Install to Workspace e copiar o Bot User OAuth Token (xoxb-…).
  4. Em cada canal escolhido: /invite @nome-da-app (um bot token só lê canais onde está).
  5. No BeryX: token, canais (#dados-problemas, id C0… ou link …/archives/C0…) e, opcionalmente, o endereço do workspace (a-tua-org.slack.com) para os links dos sinais.

Teams — do lado do cliente (a mesma app registada do SharePoint serve; ver ADR 0003/0008):

  1. Permissão mínima: RSC ChannelMessage.Read.Group (Application), concedida pelo dono de cada equipa ao instalar a app nessa equipa. Alternativa tenant-wide: ChannelMessage.Read.All (Application) com admin consent. Nenhuma permissão Chat.*.
  2. As mensagens de canais são uma protected API da Microsoft: o cliente submete o pedido de acesso (formulário Protected APIs in Microsoft Teams) para a app no seu tenant.
  3. No BeryX: tenant_id, client_id, client_secret e os canais — um link por linha («Obter ligação para o canal» no Teams) ou <team-id>/<channel-id>.

Read-only: Slack só por métodos de leitura da Web API oficial (auth.test, conversations.info|list|history|replies) via GET; Teams por GETs ao Graph v1.0. Ambos passam pelo ReadOnlyHttpClient (anti-SSRF + pinning); tokens e segredos nunca aparecem em logs nem erros.

Apache Kafka (tópicos como datasets)

O que é preciso: os bootstrap servers e uma conta só de leitura — ACL Read + Describe nos tópicos a monitorizar e, se quiseres o lag de uma aplicação, Describe no respetivo consumer group. No Confluent Cloud: uma API key de service account com esses papéis (SASL_SSL + PLAIN). Para Avro: URL e API key do Schema Registry (só leitura).

Instalação: pip install "datavault-backend[kafka]" (confluent-kafka, cliente oficial sobre o librdkafka). O Avro é decodificado sem dependências extra; o Schema Registry é lido por HTTP.

Como funciona (v1, sem consumo contínuo): cada tópico configurado (ou, sem topics, todos os não internos — até 200) é um dataset. Cada scan lê uma amostra das últimas sample_size mensagens (default 500, máximo 5000), repartida pelas partições a partir da cauda (até 64 partições, 30 s de orçamento), e perfila-as como linhas: campos de topo do JSON/registo Avro são colunas (objetos e listas aninhados ficam como texto JSON), mais _kafka_partition, _kafka_offset e _kafka_timestamp. row_count = mensagens retidas (soma de high − low watermark).

Métricas próprias (em profile_results.metrics_json.source_metrics da linha de tabela):

  • last_message_age_seconds / latest_message_at — frescura dos eventos; o latest_timestamp do perfil é o timestamp da mensagem mais recente, por isso a regra de freshness e os monitores automáticos funcionam sem nada de especial;
  • malformed_pct / malformed_reasons — mensagens com valor que não se conseguem ler como objeto (invalid_json, not_an_object, invalid_avro, schema_not_found, schema_registry_missing, missing_schema_id, unsupported_schema_type); tombstones (valor nulo) contam à parte em tombstones;
  • sample_lag — mensagens que ficaram por ler na cabeça das partições quando a amostra terminou (produção durante o scan ou leitura cortada pelo orçamento; sample_timed_out);
  • consumer_group_lag — com consumer_group, lag real dessa aplicação (high watermark − offset committed), lido com OffsetFetch;
  • schema_conformity — com schema_registry_url, quantas mensagens da amostra conformam com o schema registado (checked, violations, violation_pct, reasons, schema_ids, subject); null sem registry. Ver «Regras sobre streams» abaixo.

Os monitores automáticos usam só watermarks (change_counter = soma dos high watermarks) e o timestamp da última mensagem de cada partição.

Formatos: value_format = auto (default: wire format Confluent → Schema Registry; caso contrário JSON), json ou avro. Schemas JSON do registry são suportados; PROTOBUF fica fora do v1 (conta como malformada).

Regras sobre streams (cartão #103). Quatro tipos de regra ao nível do tópico, avaliados na amostra de cada scan; quando falham abrem issues (rule_violation, com o tipo em subtype) e entram no verdict do dataset (GET /datasets/{id}/verdict) exatamente como as restantes — uma regra critical a falhar dá fail.

Tipo Parâmetros Passa quando Dimensão do score
max_event_age max_age_seconds (obrigatório), column? o evento mais recente da amostra tem ≤ N segundos. Sem column usa o timestamp do broker (last_message_age_seconds); com column usa o maior event-time desse campo do payload (ISO-8601, epoch em s ou ms) atualidade
schema_conformity max_violation_pct? (default 0) % de mensagens fora do schema do Schema Registry ≤ limite validade
required_fields fields (lista ou texto separado por vírgulas; a.b para campos aninhados), max_violation_pct? (default 0) % de eventos sem algum dos campos (ausente ou null) ≤ limite validade
max_malformed_pct max_pct (obrigatório) malformed_pct da amostra ≤ limite (tombstones não contam) validade

Conformidade com o schema: mensagens com o wire format da Confluent são validadas contra o schema do id que trazem — Avro que decodifica é conforme (invalid_avro e schema_not_found são violações); schemas JSON validam o subconjunto usual do JSON Schema (type, required, properties, additionalProperties: false, enum/const, items, anyOf/oneOf/allOf, $ref local). Mensagens JSON sem framing são validadas contra a última versão do subject <tópico>-value (TopicNameStrategy), seja ele JSON Schema ou Avro (campos sem default são obrigatórios; campos extra são tolerados). Motivos: missing_required, type_mismatch, enum_mismatch, unexpected_field, no_branch_matched, invalid_json, invalid_avro, schema_not_found. Sem registry, ou sem nenhuma mensagem com schema, a regra falha com a explicação em details.error (tal como freshness sem timestamp). Só contadores e códigos saem do conector — nunca payloads.

Onboarding: no primeiro scan de um tópico, além das regras genéricas, são criadas max_malformed_pct (≤ 1%, warning) e — se o registry validou alguma mensagem — schema_conformity (0%, warning).

Exemplos (POST /api/v1/rules, ou a secção rules do YAML de GitOps):

{"dataset_id": "<orders>", "rule_type": "max_event_age", "params": {"max_age_seconds": 300}, "severity": "critical"}
{"dataset_id": "<orders>", "rule_type": "max_event_age", "params": {"max_age_seconds": 900, "column": "event_time"}}
{"dataset_id": "<orders>", "rule_type": "schema_conformity", "params": {"max_violation_pct": 0.5}}
{"dataset_id": "<orders>", "rule_type": "required_fields", "params": {"fields": ["order_id", "customer.id"], "max_violation_pct": 0}}
{"dataset_id": "<orders>", "rule_type": "max_malformed_pct", "params": {"max_pct": 1}}
rules:
  - dataset: orders
    type: max_event_age
    params: {max_age_seconds: 300}
    severity: critical
  - dataset: orders
    type: required_fields
    params: {fields: [order_id, customer.id]}

Leitura do resultado: observed é a idade em segundos (max_event_age) ou a percentagem observada (restantes); details traz source (broker_timestamp, event_timestamp num ficheiro de logs, rows ou profile), reasons por motivo, missing_by_field por campo e checked_count. max_event_age e required_fields também funcionam fora de streams (usam latest_timestamp / o perfil das colunas); schema_conformity só em streams (Kafka e Pub/Sub — ver abaixo) e max_malformed_pct também nos ficheiros de logs dos buckets.

Segurança / read-only: o consumidor usa assign() — nunca entra num consumer group, nunca provoca rebalances e nunca faz commit (enable.auto.commit=false). O config do librdkafka é montado a partir de uma allowlist (nenhuma propriedade arbitrária do cliente passa: ssl.ca.location, plugins ou comandos Kerberos seriam LFI/RCE); CA própria só como PEM (ssl_ca_pem). Bootstrap servers, brokers anunciados nos metadados e o Schema Registry passam pela guarda anti-SSRF; o registry só por GET no ReadOnlyHttpClient e exige https. Payloads nunca são logados; as métricas só guardam contadores.

Google Pub/Sub (tópicos como datasets — par do Kafka)

O que é preciso: uma chave JSON de service account do teu projeto GCP e, em cada tópico a monitorizar, uma subscrição pull dedicada ao BeryX com a label datavault-sampler=true:

gcloud pubsub subscriptions create datavault-orders --topic=orders \
  --labels=datavault-sampler=true --message-retention-duration=1h --expiration-period=never
gcloud pubsub subscriptions add-iam-policy-binding datavault-orders \
  --member=serviceAccount:datavault@<projeto>.iam.gserviceaccount.com --role=roles/pubsub.subscriber

Papéis da service account: roles/pubsub.subscriber só nas subscrições de amostragem; roles/pubsub.viewer no projeto (ler a configuração das subscrições e os schemas dos tópicos); roles/monitoring.viewer se quiseres o backlog das subscrições dos teus consumidores.

Instalação: pip install "datavault-backend[pubsub]" (google-cloud-pubsub + google-cloud-monitoring, clientes oficiais). Avro reutiliza o decoder próprio da base de streaming.

Decisão — como amostrar sem roubar mensagens. No Pub/Sub ler é fazer pull numa subscrição, e cada subscrição tem o seu cursor. Alternativas avaliadas:

Opção Porque não
Pull na subscrição dos consumidores sem ack As mensagens ficam retidas pelo BeryX até expirar o ack deadline (atraso para a aplicação), delivery_attempt sobe e, com dead-letter policy, podem acabar na DLQ. Rouba mensagens.
Snapshot + seek na subscrição dos consumidores Exige pubsub.snapshots.create/seek (escrita no projeto do cliente); durante a amostra as mensagens ficam retidas; o seek de volta reentrega aos consumidores tudo o que processaram nesse intervalo (duplicados).
Subscrição de amostragem dedicada (escolhida) Cada subscrição recebe a sua própria cópia de cada mensagem: pull + ack nela não mexe em nenhuma outra. Custo: throughput de entrega só das mensagens puxadas (≤ sample_size por scan); o backlog não confirmado é descartado pela retenção.

O conector recusa antes de qualquer pull subscrições sem a label datavault-sampler=true, push, de exportação (BigQuery/Cloud Storage) ou desanexadas, e duas subscrições de amostragem no mesmo tópico. O seek usa-se, sim, mas só na subscrição dedicada: antes de cada amostra ela recua para agora − sample_window_seconds (default 3600; 0 desliga). Isto descarta backlog antigo — sem isso, um scan diário amostraria mensagens de ontem e a frescura daria falsos alarmes — e, com retain_acked_messages ou retenção no tópico, repõe a janela já lida.

Como funciona: cada subscrição em subscriptions (id, ou projects/<p>/subscriptions/<id>) dá um dataset com o nome do tópico (schema = projeto). Cada scan faz pull de até sample_size mensagens (default 500, máximo 5000, 30 s de orçamento, para após dois pulls vazios) e confirma-as; perfil, colunas, linhas das regras e contagem usam a mesma amostra (um só pull por scan). Colunas = campos de topo do JSON/Avro, mais _pubsub_message_id e _pubsub_publish_time. row_count = mensagens na amostra. Os monitores automáticos (table_metadata) nunca fazem pull — consumiriam a subscrição dedicada a cada ciclo.

Métricas (source_metrics, kind: "pubsub") — o núcleo é o mesmo do Kafka (streaming.sample_metrics), por isso as regras de streams, a freshness e as sugestões de onboarding funcionam iguais:

  • latest_message_at / last_message_age_seconds — pelo publish_time; latest_timestamp do perfil = o mais recente;
  • malformed_pct / malformed_reasons (invalid_json, not_an_object, invalid_avro, schema_not_found, schema_unavailable, missing_schema, unsupported_schema_type); mensagens sem data (só atributos) contam em tombstones;
  • consumer_group_lag — com consumer_subscriptions (as subscrições reais dos teus consumidores), soma do num_undelivered_messages das que leem o tópico, lida no Cloud Monitoring (nunca pull nelas); detalhe por subscrição em consumer_subscriptions e consumer_oldest_unacked_age_seconds;
  • schema_conformity — em tópicos com schema do Pub/Sub, a partir dos atributos googclient_schemaname/googclient_schemaencoding/googclient_schemarevisionid de cada mensagem: Avro BINARY que decodifica é conforme; JSON é validado contra o schema Avro dessa revisão. Protobuf binário fica fora (malformada unsupported_schema_type). null em tópicos sem schema — a regra explica;
  • sampling_subscription, sample_window_start, subscription_filter (a amostra respeita o filtro da subscrição), ack_failed.

Segurança / read-only: nunca publica nem cria/altera/apaga recursos; as únicas escritas são o cursor da subscrição dedicada (ack e seek), verificada pela label antes de qualquer pull/seek. Endpoints fixos da Google (sem hosts vindos do config); só chave de service account (token_uri fixado em oauth2.googleapis.com, universo googleapis.com), nunca ADC do servidor — validado na criação da conexão. Payloads, chave e tokens nunca são logados.

Microsoft Fabric (SQL analytics endpoint)

O que é preciso: um service principal do Entra ID com acesso ao workspace Fabric, o driver ODBC 18 da Microsoft no sistema, e o SQL analytics endpoint do Lakehouse/Warehouse.

Passo a passo:

  1. App registration + segredo: igual aos passos 1-2 do Dynamics (podes reutilizar a mesma app) → tenant_id, client_id, client_secret.
  2. Ativar service principals no Fabric: app.fabric.microsoft.com → Settings → Admin portal → Tenant settings → Developer settings → "Service principals can use Fabric APIs" → Enabled (o admin do tenant pode restringir a um grupo de segurança que inclua a app).
  3. Acesso ao workspace: no workspace → Manage access → + Add people or groups → adiciona o service principal com papel Viewer (suficiente para leitura via SQL endpoint).
  4. Endpoint e base de dados: no Lakehouse/Warehouse → Settings → SQL analytics endpoint → SQL connection string — é o server (ex.: xxxx-yyyy.datawarehouse.fabric.microsoft.com); database é o nome do Lakehouse/Warehouse tal como aparece no workspace.
  5. Driver ODBC de sistema (obrigatório, além do extra pip):
    • macOS: brew tap microsoft/mssql-release https://github.com/Microsoft/homebrew-mssql-release && HOMEBREW_ACCEPT_EULA=Y brew install msodbcsql18
    • Debian/Ubuntu: seguir as instruções da Microsoft e sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18

Campos do wizard: server, database, tenant_id, client_id, client_secret (opcionais: port, odbc_driver).

Instalação: pip install "datavault-backend[fabric]" + driver ODBC acima.

Limitações conhecidas:

  • O SQL analytics endpoint de um Lakehouse é read-only por natureza (ideal para o BeryX), mas os dados Delta podem demorar alguns minutos a refletir-se no endpoint após escrita.
  • Schemas de sistema (INFORMATION_SCHEMA, sys, queryinsights) são excluídos da descoberta de tabelas.
  • A ligação exige Encrypt=yes (TLS) — o BeryX configura-o automaticamente; ambientes com inspeção TLS podem precisar dos certificados corretos no sistema.
  • Sem o driver ODBC de sistema, o teste de ligação devolve as instruções de instalação brew/apt.
  • Checksums, sketches HLL e amostra de linhas pelo hash da chave usam HASHBYTES('SHA2_256', CAST(col AS VARCHAR(max))): o Fabric Warehouse não tem NVARCHAR (o VARCHAR é UTF-8). Por isso o checksum_dialect é fabric e o data-diff não compara checksums do Fabric com os de um SQL Server (os bytes do texto são outros). Se a fonte recusar a amostra por hash, o scan usa as primeiras linhas e regista sample_hash_fallback no log do worker.

SQL Server / Azure SQL / Synapse (mssql)

A fonte dos ERPs portugueses (Primavera, PHC, Sage) e do mid-market Microsoft. Um só tipo cobre SQL Server on-premises (2016+), Azure SQL Database/Managed Instance e Synapse (dedicated e serverless SQL pools) — todos falam T-SQL pelo mesmo dialeto.

Dois modos de autenticação (o wizard escolhe pela presença de tenant_id):

Modo Para quê Driver Campos
Autenticação SQL SQL Server on-prem/VM, Azure SQL com login SQL mssql+pymssql — wheels com FreeTDS embutido, sem driver ODBC de sistema (funciona na imagem de produção) host, port (1433), database, user, password
Entra ID service principal Azure SQL, Synapse mssql+pyodbc com Authentication=ActiveDirectoryServicePrincipal — o mesmo caminho do Fabric host, database, tenant_id, client_id, client_secret (opcional: port, odbc_driver)

Em ambos: application_intent: "readonly" (opcional) envia ApplicationIntent=ReadOnly, que num Availability Group com réplicas legíveis ou no Azure SQL read scale-out encaminha a sessão para a réplica de leitura (que recusa escritas e não carrega o primário). O host aceita também o formato do portal Azure (tcp:servidor.database.windows.net,1433). Outros prefixos de protocolo (np:, lpc:, admin:) são recusados. O anti-SSRF (netguard) valida o host já sem o prefixo tcp:, sem a porta ,1433 e sem a instância nomeada (\\INSTANCIA), na criação da conexão e outra vez em runtime antes de cada ligação.

Login read-only (SQL Server / Azure SQL com autenticação SQL) — o SQL Server não tem um modo read-only de sessão equivalente ao default_transaction_read_only do Postgres: a garantia vem dos GRANTs. Cria um login só com db_datareader:

-- no master (SQL Server) — em Azure SQL, na base master do servidor lógico
CREATE LOGIN datavault_ro WITH PASSWORD = '<password forte>';
-- em CADA base de dados a analisar
USE <base>;
CREATE USER datavault_ro FOR LOGIN datavault_ro;
ALTER ROLE db_datareader ADD MEMBER datavault_ro;
-- opcional: contagens de linhas pelo catálogo de partições (sem isto usa sys.partitions)
GRANT VIEW DATABASE STATE TO datavault_ro;   -- SQL Server 2022/Azure SQL: VIEW DATABASE PERFORMANCE STATE

Em Azure SQL podes, em alternativa, criar um contained user diretamente na base (CREATE USER datavault_ro WITH PASSWORD = '…') e ligar com esse utilizador.

Service principal (Azure SQL / Synapse):

  1. App registration + segredo: igual aos passos 1-2 do Dynamics (podes reutilizar a app) → tenant_id, client_id, client_secret.
  2. Admin Entra no servidor: o servidor lógico tem de ter um administrador Entra ID definido (portal → SQL server → Microsoft Entra ID → Set admin).
  3. Utilizador na base, ligado como admin Entra:
    CREATE USER [nome-da-app] FROM EXTERNAL PROVIDER;
    ALTER ROLE db_datareader ADD MEMBER [nome-da-app];
    
    Synapse dedicated pool: o mesmo, na base do pool. Synapse serverless: na base criada sobre o lake, e o service principal precisa ainda de ler o storage (Storage Blob Data Reader) para as views/external tables.
  4. Firewall: adiciona o IP de saída do BeryX às regras de firewall do servidor lógico (erro 40615 = IP recusado).
  5. Driver ODBC 18 de sistema (instruções na secção Fabric) + pip install "datavault-backend[mssql,fabric]".

Não disponível na imagem de produção. A imagem do BeryX (cd.yml/gcp_deploy.sh) traz o extra [mssql] (pymssql), mas não o [fabric] (pyodbc) nem o ODBC Driver 18 de sistema. Nessa instalação o GET /connections/types devolve unavailable_modes: {"entra": "fabric"} e o wizard desativa o botão «Entra ID», com a indicação do extra em falta. Em self-hosted, instala os dois para ativar o modo.

TLS: sempre. No modo Entra (ODBC) vai Encrypt=yes;TrustServerCertificate=no — certificado verificado. No modo SQL auth o pymssql pede cifra obrigatória (encryption=require), mas o FreeTDS embutido nas wheels não valida o certificado do servidor (não há CA configurável por ligação): a sessão é cifrada, mas não protegida contra um intermediário com certificado falso. Para verificação completa do certificado usa o modo Entra ID (ou um url mssql+pyodbc:// com login SQL, que exige o driver ODBC).

O que o conector faz:

  • Descoberta exclui os schemas sys, INFORMATION_SCHEMA, guest e as roles fixas db_*.
  • Profiling por push-down T-SQL: contagens, nulos, distintos, min/max, média/desvio, zeros/negativos; quantis por NTILE(20) (o PERCENTILE_CONT do T-SQL só existe como função de janela); bit fica fora do min/max; comprimentos de texto não são calculados (LEN() ignora espaços finais — números enganadores). varbinary fica fora de distintos/min/max; tipos exóticos (xml, geography, sql_variant, hierarchyid) degradam para a contagem de nulos sem falhar o scan.
  • Metadados baratos (monitores automáticos): contagem de linhas por sys.dm_db_partition_stats (heap/índice clustered, sem scan; com um login só db_datareader cai para sys.partitions). Só no conector mssql: o Fabric mantém o comportamento anterior (sem contagem de catálogo) porque estas vistas não foram validadas num Fabric Warehouse. sys.tables.modify_date é exposto como última alteração de esquema — muda com DDL, não com INSERT/UPDATE, por isso nunca é usado como frescura dos dados; a frescura vem de uma coluna temporal da tabela.
  • Timeout de query do driver (timeout do pymssql / Connection.timeout do pyodbc) a partir de DATAVAULT_CONNECTOR_STATEMENT_TIMEOUT_S — não há statement_timeout de sessão em T-SQL.
  • Checksums e sketches HLL (data-diff/INCR) pelos primeiros 4 bytes de HASHBYTES('SHA2_256', CAST(col AS NVARCHAR(max))) (cartão #317). Não se usa o CHECKSUM: segue a collation, e numa collation CI «A» e «a» davam o mesmo hash — um falso «igual» no data-diff.
  • Amostra de linhas pelo hash da chave primária (ver Amostra de linhas); sem chave, as primeiras linhas por ordem da 1.ª coluna.
  • Password e client_secret nunca aparecem nas mensagens de erro (são removidos mesmo quando o driver ecoa o connection string).

url completa: mssql+pymssql://user:pass@host:1433/base ou mssql+pyodbc://… — só com os params da allow-list do dialeto mssql (tabela na secção PostgreSQL / MySQL / SQLite). No pymssql, connect_timeout e applicationintent=readonly são traduzidos para os kwargs do driver.

Fora do âmbito: CDC, Change Tracking e sys.dm_exec_* (linhagem/queries); autenticação Windows/Kerberos e ActiveDirectoryPassword na UI.

Oracle Database (oracle)

É a base de dados de muitos ERPs legados, da banca, das telcos e do setor público. O conector usa o python-oracledb em modo thin: fala o protocolo Oracle Net em Python puro, sem Instant Client nem bibliotecas de sistema, por isso funciona na imagem de produção (extra [oracle]). Precisa do Oracle Database 12.1 ou mais recente, o mínimo do modo thin.

Campos: host (só o nome ou o IP), port (1521), service_name ou sid, user, password e, opcional, schemas (lista, ou texto separado por vírgulas). A ligação vai sempre para o host e a port indicados, com o serviço ou o SID explícitos: o conector não lê aliases TNS, tnsnames.ora nem wallets do servidor. Um host com porta, caminho, ?, ( ou = (Easy Connect com parâmetros, descritor (DESCRIPTION=…)) é recusado. O anti-SSRF valida esse mesmo host na criação da conexão e outra vez em runtime.

Utilizador read-only. Cria um utilizador só com CREATE SESSION e SELECT nas tabelas a analisar. Evita o SELECT ANY TABLE, que abre todos os schemas, incluindo os do próprio Oracle.

CREATE USER datavault_reader IDENTIFIED BY "<password forte>";
GRANT CREATE SESSION TO datavault_reader;
-- uma linha por tabela ou vista a analisar
GRANT SELECT ON hr.employees TO datavault_reader;
GRANT SELECT ON hr.departments TO datavault_reader;

Num schema com muitas tabelas, gera os GRANTs a partir do catálogo (como dono do schema ou DBA) e corre o resultado. Uma tabela criada depois precisa do seu GRANT.

SELECT 'GRANT SELECT ON ' || owner || '.' || table_name || ' TO datavault_reader;'
FROM all_tables WHERE owner = 'HR';

Read-only mecânico. Além dos GRANTs, cada transação do conector começa com SET TRANSACTION READ ONLY (listener begin do engine). Dentro dela o Oracle recusa INSERT, UPDATE, DELETE e MERGE com ORA-01456, mesmo que o utilizador tenha o GRANT. O DDL faz commit implícito e sai dessa transação: contra CREATE ou DROP vale só a falta do privilégio, por isso o utilizador não deve ter mais do que os GRANTs acima. Numa transação só de leitura, todas as consultas veem a base no mesmo instante. Num perfil longo de uma tabela muito alterada pode aparecer o ORA-01555 («snapshot too old»), como em qualquer leitura consistente longa.

O que o conector faz:

  • Descoberta: os schemas com ALL_USERS.ORACLE_MAINTAINED = 'N' mais o do próprio utilizador. Se o ALL_USERS não tiver essa coluna, todos menos uma lista fixa de schemas do Oracle (SYS, SYSTEM, XDB, MDSYS, CTXSYS, OUTLN, DBSNMP, AUDSYS, WMSYS, OLAPSYS, ORDSYS, LBACSYS, DVSYS, GSMADMIN_INTERNAL, APEX_* e outros). Com schemas, só esses. Os nomes seguem a convenção do SQLAlchemy: um identificador sem aspas, em maiúsculas no catálogo (HR.EMPLOYEES), aparece em minúsculas (hr.employees). A reflexão cita-os; nenhum nome é interpolado no SQL. Tabelas, views e materialized views saem com o tipo: uma view tem o perfil limitado das views (ver Chaves, partições e tipo do objeto).
  • Tipos: NUMBER(p, 0) e INTEGER são inteiros. NUMBER(p, s) e NUMBER sem precisão são numéricos (podem ter casas decimais) e aparecem como NUMERIC(p, s) e NUMERIC. DATE traz hora e conta como coluna temporal. CLOB, NCLOB, BLOB e LONG ficam fora de COUNT(DISTINCT), MIN/MAX e ORDER BY, que o Oracle recusa nesses tipos (ORA-00932, ORA-00997), e também do LENGTH, das contagens de vazios e só espaços e do treat_as_null da regra not_null: contam-se os nulos e as linhas de amostra trazem o valor.
  • Profiling por push-down: contagens, nulos, distintos, min/max, média e desvio-padrão (STDDEV_POP), zeros e negativos, comprimentos de texto (LENGTH, em caracteres); quantis por NTILE(20). O LIMIT sai como FETCH FIRST n ROWS ONLY.
  • Metadados baratos (monitores automáticos): a contagem é o ALL_TABLES.NUM_ROWS, marcada como estimativa (row_count_estimated, com o LAST_ANALYZED em row_count_as_of), porque é a contagem da última recolha do DBMS_STATS e não a de agora. Quando o ALL_TAB_MODIFICATIONS é visível, o DML desde essa recolha (INSERTS + UPDATES + DELETES) serve de contador de escritas. O Oracle grava essa vista com atraso e limpa-a a cada recolha, por isso o TIMESTAMP dela não é usado como frescura dos dados.
  • Timeout: o Oracle não tem statement_timeout de sessão. Vai o call_timeout do oracledb (por ida e volta à base) a partir de DATAVAULT_CONNECTOR_STATEMENT_TIMEOUT_S. Ao expirar, o driver interrompe a chamada no servidor e levanta DPY-4024, que nunca se repete. Repete-se só uma ligação perdida (tabela das repetições abaixo).
  • Amostra de linhas: até DATAVAULT_SAMPLE_FULL_SCAN_MAX_ROWS linhas, as primeiras pela ordem da chave primária (ou da 1.ª coluna que não seja LOB); acima disso, as primeiras que o Oracle devolver, sem ORDER BY, para não ordenar a tabela inteira. Este conector não tem amostragem por hash, checksums nem sketches HLL: o data-diff compara só as linhas lidas pela chave, e uma tabela acima de DATAVAULT_PROFILE_DISTINCT_MAX_ROWS fica sem o distinto aproximado do sketch. Também não tem regras SQL por push-down.
  • A password nunca aparece nas mensagens de erro.

TLS: a ligação é TCP sem TLS. Esta versão não suporta TCPS com wallet nem a Native Network Encryption, que o oracledb só tem no modo thick: um servidor que exija NNE recusa a ligação com DPY-3001. Até lá, liga por uma rede privada (VPN, interconnect).

url completa: oracle+oracledb://user:pass@host:1521/?service_name=ORCLPDB1 ou oracle+oracledb://user:pass@host:1521/SID, só com service_name, sid e connect_timeout (segundos, o tcp_connect_timeout do oracledb). O driver é sempre o oracledb em modo thin. Os outros parâmetros do oracledb.connect ficam de fora: config_dir e wallet_location leem ficheiros do servidor, https_proxy e protocol mudam o destino ou o transporte.

Fora do âmbito: modo thick (Instant Client), TCPS e wallets, Kerberos/RADIUS, SELECT ANY TABLE, V$SQL/AWR (linhagem por queries) e a amostragem nativa SAMPLE (p).

MongoDB (coleções como datasets)

Cada coleção de uma base é um dataset. O MongoDB não guarda schema: o BeryX infere-o de uma amostra aleatória da coleção e mede os campos com tipos mistos e os campos em falta. Implementação em app/infra/connectors/mongodb.py.

Ligação: uri ou campos.

Modo Campos
uri mongodb://user:password@host1:27017,host2:27017/?replicaSet=rs0 ou, no Atlas, mongodb+srv://user:password@cluster0.abcde.mongodb.net/
Campos host, port? (27017), user?, password?, auth_source? (admin)

Nos dois modos: database (a base a ler), collections? (lista; vazio = as coleções da base, até 200, sem as system.*, as views e as time-series) e sample_size? (documentos por amostra: 1 000 por omissão, 10 000 no máximo).

TLS, nos dois modos: tls_mode? e ssl_ca_pem?, com as regras do Postgres e do MySQL (TLS). Sem tls_mode, a ligação é verify-full: cifrada, com o certificado e o hostname do servidor verificados contra as CAs públicas do certifi. Uma instância com CA privada leva o PEM da CA em ssl_ca_pem. Ligar sem verificar o certificado (require) ou sem TLS (disable) pede confirmação explícita e fica na auditoria.

Do uri só passam estas opções: authSource, replicaSet, tls (ou ssl, que tem de coincidir com o tls_mode: um tls=false pede o tls_mode disable), readPreference, connectTimeoutMS, serverSelectionTimeoutMS e retryReads. A string do Atlas traz retryWrites, w e appName, que são aceites e descartadas. Qualquer outra opção recusa a conexão com 422, incluindo tlsCAFile e tlsCertificateKeyFile (leem ficheiros do servidor) e directConnection.

Utilizador só de leitura. Cria-o na base admin, com o papel read só na base a analisar:

// mongosh, ligado como administrador
use admin
db.createUser({
  user: "datavault_ro",
  pwd: passwordPrompt(),
  roles: [{ role: "read", db: "loja" }]
})

Com o utilizador na admin, o authSource é admin, o valor por omissão do modo de campos. O papel read chega para tudo o que o conector lê, $collStats incluído.

MongoDB Atlas:

  1. Database Access → Add New Database User: autenticação por password e o privilégio read só na base a analisar (ou o papel Only read any database).
  2. Network Access → IP Access List: adiciona o IP de saída do BeryX. Sem isto, a ligação falha por tempo («Não foi possível contactar o MongoDB»).
  3. Connect → Drivers: copia a string mongodb+srv://… para o campo uri, com a password do passo 1.

O que o conector lê (driver oficial pymongo):

  • ping no teste da ligação, com a latência, e list_collection_names na descoberta;
  • uma amostra aleatória por aggregate([{"$sample": {"size": N}}]), para as colunas e o perfil;
  • estimated_document_count para a contagem, dos metadados da coleção e sem ler documentos;
  • $collStats para o tamanho; se o utilizador não o puder ler, o perfil segue sem tamanho;
  • list_indexes e, para a frescura, um find().sort().limit(1) preso por hint a um índice: um por campo indexado (até 10; no perfil, só os de data) e outro pelo _id. Cada um lê a última entrada do índice. Ficam de fora os índices em que isso não acontece: os parciais, os ocultos, os com collation e os wildcard, de texto, hash ou geo;
  • find().limit() para as linhas das regras.

Não chama mais nada: tests/test_mongodb_connector.py liga o conector a um cliente falso que regista cada chamada e falha com qualquer outra. A preferência de leitura é secondaryPreferred por omissão (as leituras vão a um secundário quando o há), o retryWrites vai desligado e a amostra, a contagem e o find das linhas têm um teto de 60 s no servidor (maxTimeMS); cada leitura da frescura tem 5 s.

Schema inferido:

  • colunas = campos de topo, pela ordem em que aparecem na amostra (até 500), com o _id primeiro e sempre como texto;
  • tipo = o da maioria dos valores. Inteiros e decimais (int, long, double, Decimal128) contam como o mesmo número: um campo com os dois fica float, sem conflito;
  • objetos e arrays aninhados entram como texto JSON, como no Kafka; os subcampos não passam a colunas.

Métricas próprias (source_metrics, kind: "mongodb"), por campo em fields:

Chave O que mede
types quantos valores de cada tipo a amostra tinha
majority_type o tipo da coluna
type_conflict_pct % dos valores não nulos com outro tipo (ex.: "12,50" num campo numérico)
missing_pct % dos documentos sem o campo
present, missing, explicit_null as contagens por trás das percentagens
distinct quantos valores distintos a amostra tinha, de todos os tipos

fields_with_type_conflicts lista os campos com conflitos. estimated_document_count, storage e newest_insert_at (a hora do _id ObjectId mais recente) completam as métricas. As métricas da coluna (mín/máx, média, comprimentos) são do tipo maioritário, sobre a amostra. O null_count conta os documentos sem o campo e os null explícitos; um valor de outro tipo não conta como nulo, conta no type_conflict_pct. O distinct_count conta os valores presentes de todos os tipos, por tipo e valor, como um índice único do MongoDB: 1 e 1.0 são o mesmo valor, 1 e "1" são dois. Nulos e distintos ficam assim na mesma base, e uma chave toda distinta com alguns valores em texto não aparece com repetidos na regra unique.

Contagem e amostra. O row_count vem do estimated_document_count e o perfil marca-o como estimativa (row_count_estimated) e como amostrado (sample_rows). Quando a amostra lê a coleção inteira (menos documentos do que o sample_size), a contagem é exata e o perfil não é amostrado.

Frescura. Num perfil amostrado, o máximo de um campo de data indexado sai do índice, exato, e substitui o máximo da amostra (o valor da amostra fica em exact_column_metrics). Nos monitores automáticos, last_modified é o mais recente entre esses máximos e a hora do _id ObjectId mais recente; datas mais de 24 h no futuro (prazos, validades) ficam de fora. Sem índices de data, a frescura das regras vem do máximo da amostra.

Segurança:

  • o uri nunca chega ao driver: mongodb_target, em app/infra/connectors/netguard.py, extrai hosts, credenciais e opções, e o conector abre o MongoClient só com esse resultado;
  • cada host passa pela guarda anti-SSRF na criação da conexão e outra vez antes de cada ligação. Num mongodb+srv://, o BeryX resolve o registo SRV, recusa alvos fora do domínio do cluster e valida cada alvo; do registo TXT só aproveita authSource e replicaSet;
  • o pymongo resolve cada nome pela guarda (netguard.guarded_getaddrinfo, cartão #564): uma só resolução por ligação, recusada se algum endereço for interno, e o driver liga aos endereços dessa resolução, sem voltar ao DNS. Um nome que mude para a rede interna entre a validação e a ligação (DNS rebinding) não chega a abrir um socket. Vale para todas as ligações do driver, as da monitorização incluídas. O TLS continua a verificar o certificado contra o nome. Um pymongo anterior ao 4.12 é recusado, porque não tem o ponto onde a guarda entra;
  • os membros que o replica set anuncia passam pela mesma guarda: a monitorização do driver só os contacta, sem credenciais, se a resolução passar; as operações, e com elas a autenticação, só vão para os membros que a guarda aceita (server_selector). Se algum membro cair num destino bloqueado, a ligação é fechada e recusada;
  • sockets locais são sempre recusados, também com DATAVAULT_ALLOW_PRIVATE_NETWORKS=true: no uri e no host, um caminho (/tmp/mongodb-27017.sock) ou um host terminado em .sock, com ou sem porta, que o pymongo ligaria por socket Unix sem resolver o nome; dentro do driver, um membro .sock anunciado pelo replica set não chega a abrir um socket, nem na monitorização, que o contacta antes do server_selector (cartão #602);
  • em verify-full, o pymongo recebe tlsCAFile com o bundle do certifi ou com a CA do cliente, escrita num ficheiro temporário 0600 que é apagado no close() (tls.mongodb_tls_args). O tlsCAFile do uri continua recusado, porque leria um ficheiro do servidor;
  • os documentos nunca vão para os logs.

Fora do âmbito: views e coleções time-series, subcampos como colunas, change streams e autenticação X.509, LDAP ou Kerberos (authMechanism está fora das opções aceites).

Salesforce

O que é preciso: utilizador (idealmente read-only), password e security token.

Passo a passo:

  1. Security token: com sessão iniciada → avatar → Settings → My Personal Information → Reset My Security Token — chega por email. (Se a organização usa IP ranges confiáveis, o token pode não ser exigido, mas o conector pede-o sempre.)
  2. Recomendado: um utilizador de integração com um Permission Set apenas de leitura nos objetos a analisar.
  3. domain: login (produção) ou test (sandbox).

Campos do wizard: username, password, security_token, domain.

Instalação: pip install "datavault-backend[salesforce]"

Limitações conhecidas: o MVP expõe os sObjects Account, Contact, Lead e Opportunity; profiling básico (contagens via SOQL, sem métricas por coluna); consome chamadas API da org.

Mensagens de erro (cartão #611). A mensagem do teste de ligação fica no último erro da conexão. Quando nenhum objeto do scan é analisado, o último erro do conector fica no erro do scan. Os membros do workspace veem os dois. O simple-salesforce põe no erro o URL pedido e o corpo da resposta, por isso estas mensagens passam pelo filtro dos conectores SQL (cartão #590): o valor da password, do security_token e do token da sessão aberta sai como ***. A password sai assim também na forma com que o login a envia, escapada com html.escape (um & passa a &amp;). Prova: backend/tests/test_salesforce_connection_redaction.py.

CSV

O que é preciso: um diretório local (ou montado) com ficheiros .csv — cada ficheiro é tratado como uma tabela.

Campos do wizard: path (diretório).

Instalação: funciona sem extras (stdlib); pip install "datavault-backend[csv]" adiciona pandas para partir o texto mais depressa. Os tipos são os mesmos com e sem pandas.

Como o ficheiro é lido (cartão #289, em app/infra/connectors/csv_file.py):

  • Encoding: o BOM decide primeiro (UTF-8 ou UTF-16, o «Texto Unicode» do Excel). Sem BOM, tenta utf-8 → cp1252 → latin-1. Um ficheiro latin-1 só com letras acentuadas é também cp1252 válido e é reportado como cp1252, com o mesmo texto.
  • Delimitador: é decidido pelo csv.Sniffer sobre os primeiros 64 KiB, só entre , ; tab e |. Quando não há veredicto (ficheiro de uma coluna), fica ,. O encoding e o delimitador ficam no perfil da tabela (file_format, ex.: {"delimiter": ";", "encoding": "cp1252"}).
  • Tipos: a coerção é do BeryX, não do pandas (app/infra/connectors/profiling.py). Um valor só passa a inteiro sem zeros à esquerda ("0" sim, "08001" não), por isso códigos postais, SKUs, NIF e telefones ficam texto. Só o campo vazio sem aspas é NULL: N/A e - são valores e ficam visíveis no perfil. Como no COPY … CSV do Postgres, o campo vazio sem aspas é NULL e "" entre aspas é texto vazio (empty_count), em Python 3.12 ou mais recente (a imagem Docker usa o 3.13; em 3.11 o leitor não os distingue e os dois contam como NULL); num ficheiro com o cabeçalho todo entre aspas e sem nenhum campo vazio sem aspas, o escritor citou tudo e o "" conta como NULL. Numa coluna numérica, de datas ou booleana, '' é sempre NULL (app/infra/connectors/csv_file.py, parse_csv_lines e coerce_table). O GCS/S3 lê os CSV pelo mesmo leitor. NaN/inf numa coluna numérica contam como float, com nan_count à parte dos nulos. Datas só em ISO-8601, e todas com fuso ou todas sem; uma coluna que mistura as duas fica texto, porque nenhum fuso é inferido. Os literais true/false (True, TRUE…) são booleanos.
  • Tetos: o perfil lê até DATAVAULT_CSV_PROFILE_MAX_ROWS linhas (1 000 000) e DATAVAULT_CSV_PROFILE_MAX_BYTES bytes (256 MB). Acima disso o perfil é das primeiras linhas e diz sampled e sample_rows (a UI mostra «perfil sobre amostra de N linhas»).
  • Amostra de linhas (regras de linha, duplicados; cartão #317): uma linha em cada k das que o perfil lê, a partir de semente mod k — espalha-se pelo ficheiro lido em vez de ficar nas primeiras 5 000.

Limitações conhecidas: decimais PT (1.234,56) e datas dd/mm/aaaa ficam texto (nenhum locale é inferido; a máscara de formato do perfil descreve-os). Números com mais de ~16 dígitos significativos perdem precisão (float). Num CSV não se distingue string vazia de NULL. O caso a caso está no corpus adversarial.

Google Cloud Storage / Amazon S3 (ficheiros como tabelas)

Data lake em object storage. Cada ficheiro *.parquet, *.csv ou *.csv.gz sob bucket/prefix é uma tabela, com o caminho relativo ao prefixo (sem extensão) como nome. Os ficheiros de eventos e logs (*.jsonl, *.ndjson, *.log e os mesmos em .gz) também, com uma linha por evento (ver abaixo). Os CSV são lidos como no conector CSV: encoding e delimitador pelo início do ficheiro (64 KiB), dtype=str no pandas e a mesma coerção de tipos. O mesmo ficheiro dá o mesmo perfil no disco e num bucket. Os Parquet mantêm os tipos nativos. As colunas de texto chamam-se text neste conector. Implementação comum em app/infra/connectors/object_storage.py (fsspec).

Credenciais sempre explícitas (A2): ou a credencial do cliente, ou anonymous: true para buckets públicos. Sem nenhuma das duas, a conexão é recusada: o BeryX nunca usa as credenciais do próprio servidor (ADC do Cloud Run, cadeia boto). anonymous tem de ser um booleano ("false" em texto é recusado).

Google Cloud Storage — do lado do cliente:

  1. Na consola Google Cloud: IAM & Admin → Service Accounts → Create service account (ex.: datavault-reader), sem papéis ao nível do projeto.
  2. No bucket: Permissions → Grant access à service account com Storage Object Viewer (roles/storage.objectViewer), que lista e lê objetos e não escreve nada.
  3. Na service account: Keys → Add key → Create new key → JSON.
  4. No wizard: bucket (sem gs://), prefix opcional (ex.: gold/vendas) e o conteúdo completo do JSON no campo «JSON da service account» (vai como service_account_info). Para um bucket público, marca «Acesso anónimo» e deixa a chave em branco.

Com credenciais, o passo 2 da nova conexão mostra o Guia de registo Google Cloud e o botão Testar acesso (ver a secção BigQuery).

Amazon S3 (e compatíveis) — do lado do cliente:

  1. Um utilizador IAM (ou role com chaves de acesso) só de leitura, com uma política deste género:
    {
      "Version": "2012-10-17",
      "Statement": [
        {"Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::<bucket>"},
        {"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::<bucket>/<prefixo>/*"}
      ]
    }
    
  2. Security credentials → Create access key → access_key_id e secret_access_key.
  3. No wizard: bucket, prefix opcional, as duas chaves (ou «Acesso anónimo» para buckets públicos do AWS Open Data), region opcional (ex.: eu-west-1) e endpoint_url opcional para serviços compatíveis com S3 (MinIO, Cloudflare R2, …).

Campos: bucket, prefix?, max_files? (200 por omissão) e as credenciais acima. protocol não é configurável: um conector gcs só fala gcs/gs e um s3 só s3 (sem isto, file ou http davam leitura local ou SSRF).

Instalação: pip install "datavault-backend[gcs]" ou pip install "datavault-backend[s3]" (as duas vão na imagem de produção). Os PDF da análise de documentos precisam também do extra [documents]; os PDF digitalizados (sem camada de texto) precisam do extra [ocr] e do Tesseract do sistema (os três vão na imagem de produção).

Limitações conhecidas:

  • A descoberta lista no máximo max_files ficheiros (200 por omissão), por ordem alfabética do caminho. Os Parquet/CSV entram primeiro no teto e os ficheiros de eventos e logs só depois, por isso um .log novo nunca tira o lugar a um dataset que já estava no catálogo. Um ficheiro de eventos com o nome de um Parquet/CSV (app.jsonl ao lado de app.csv) fica de fora; entre dois ficheiros de eventos com o mesmo nome (app.jsonl e app.jsonl.gz) fica o primeiro no alfabeto.
  • O perfil lê as primeiras 100 000 linhas de cada ficheiro (os Parquet só pelos row-groups necessários). As métricas das colunas (nulos, distintos, média, desvio, amostras) são dessa amostra, não do ficheiro inteiro, e o perfil diz sampled/sample_rows quando o ficheiro continuava — cada coluna leva from_sample (a UI mostra «perfil sobre amostra de N linhas»). Perfila no máximo 120 colunas (DATAVAULT_PROFILE_MAX_COLUMNS), escolhidas pela prioridade de tabelas largas; o catálogo guarda todas.
  • O row_count é sempre do ficheiro inteiro, mesmo num perfil amostrado: a regra row_count_min, o monitor de volume e o drift row_count_drop veem o número real. Parquet: num_rows do footer, sem ler dados; quando todos os row-groups têm estatísticas, os nulos (qualquer tipo) e o mín/máx (inteiros, floats, timestamps e datas) de cada coluna passam a ser exatos e a coluna lista-os em exact_metrics — strings ficam com o mín/máx da amostra (os writers truncam essas estatísticas). CSV/CSV.GZ: segunda passagem em streaming (csv.reader, células com quebras de linha entre aspas contam uma linha) até DATAVAULT_OBJECT_STORAGE_COUNT_MAX_BYTES (512 MB, medidos a cada leitura nos bytes do ficheiro e, num .gz, também nos descomprimidos — um .gz muito compressível expande milhares de vezes e a contagem para na primeira medida que passar o teto, mesmo a meio de uma linha); acima disso a contagem é extrapolada pela razão observada (linhas contadas × tamanho do ficheiro ÷ bytes do ficheiro consumidos — num .gz, os comprimidos que o inflate consumiu) e o perfil diz row_count_estimated (a UI mostra «contagem de linhas estimada»; o drift de volume di-lo na descrição). Ficheiros abaixo das 100 000 linhas não fazem segunda leitura; um .gz truncado ou corrompido, ou uma célula acima de 64 Mi caracteres, depois da amostra não chumba o perfil (a contagem para aí e fica estimada). A contagem sobe o limite de campo do módulo csv do worker para 64 Mi caracteres (global ao processo, fica subido). Nota: nas colunas float de Parquet o pandas funde NaN com NULL na amostra — com estatísticas no footer o null_count conta só NULL.
  • Os distintos, a média, os zeros e as amostras de valores continuam a ser da amostra. Por isso, quem os compara com o volume usa as linhas lidas e não o row_count do ficheiro inteiro. É o caso da regra unique de uma coluna, das sugestões de chave, dos alertas e tipos semânticos do perfil, do gerador de dados sintéticos e da estimativa da regra no browser. Num Parquet com nulos exatos do footer, a coluna guarda os nulos da amostra em sample_null_count. Sem esta base, uma chave única de um ficheiro acima das 100 000 linhas aparecia cheia de repetidos (método).
  • Um CSV em ASCII nos primeiros 64 KiB e em cp1252 mais à frente falha na leitura com a mensagem do pandas, em vez de trocar caracteres em silêncio.
  • Ficheiros Parquet e CSV acima de 2 GB são recusados no perfil («Particiona a origem»). Os ficheiros de eventos e logs não têm este teto: são lidos em streaming só até ao teto de linhas.
  • Sem monitores automáticos baratos: não há metadados de catálogo. A frescura vem da primeira coluna temporal do ficheiro.
  • Cada scan faz pedidos GET e transfere dados do bucket, e o fornecedor cloud cobra-os.
  • O endpoint_url do S3 passa pela guarda anti-SSRF. No SaaS tem de ser um endpoint público; um MinIO numa rede privada só é alcançável em self-hosted com DATAVAULT_ALLOW_PRIVATE_NETWORKS=true.

Ficheiros de eventos e logs (cartão #183): .jsonl, .ndjson, .log, .jsonl.gz, .ndjson.gz e .log.gz (webhooks arquivados, access logs, logs de aplicações). Cada linha é um evento, lido por esta ordem: JSON, syslog (RFC 5424 e RFC 3164), nginx/apache (combined e common) e logfmt key=value (pelo menos dois pares). Uma linha que não encaixa em nenhum conta como malformada, com o motivo (invalid_json, not_an_object, unparseable, line_too_long acima de 1 MiB). Os logs no formato por omissão do logging do Python ou do log4j (2026-10-09 10:00:00,123 INFO app: …) não encaixam em nenhum: todas as linhas contam como unparseable e a regra max_malformed_pct falha sempre. Para os medir, a aplicação tem de escrever os logs em JSON ou em logfmt. Um timestamp que em UTC sai dos anos 1 a 9999 (9999-12-31T23:59:59-03:00, por exemplo) ou que traz um fuso de 24 horas ou mais não torna a linha malformada: o evento conta, mas sem timestamp legível. Num access log a data faz parte do formato, e uma data assim faz a linha contar como unparseable. As colunas vêm das chaves dos eventos, como num tópico Kafka: as primeiras 1 000 chaves distintas, pela ordem em que aparecem (columns_capped diz se havia mais). Os exports do Cloud Logging para o GCS são ficheiros .json e não entram: um .json num bucket tanto pode ter um evento por linha como um documento inteiro. Implementação em app/infra/connectors/event_files.py.

  • Leitura: as primeiras DATAVAULT_LOGFILE_MAX_LINES linhas (50 000 por omissão), em blocos de 64 KiB e com o .gz descomprimido em streaming: o ficheiro nunca fica todo em memória. A leitura também para aos 256 MiB descomprimidos ou aos 30 s. Se o ficheiro continuava, o perfil diz sampled e o número de linhas é extrapolado pelos bytes lidos (row_count_estimated). Um .gz truncado depois de linhas lidas não chumba o perfil: a leitura para aí.
  • Métricas (source_metrics com kind: "logfile"): formato dominante, linhas lidas, % de malformadas e motivos, % de linhas com timestamp legível, % de pares de linhas por ordem cronológica, maior intervalo entre eventos (segundos), distribuição por nível (debug, info, warning, error, critical e other para um nível que não se reconhece), número de conjuntos de chaves distintos (mais do que um indica mudança de schema entre versões da app), os 20 templates de mensagem mais frequentes com a contagem de cada um e pii_kinds, o número de mensagens com cada tipo de dado pessoal (email, NIF com dígito de controlo, IBAN e telemóvel portugueses, cartão de pagamento que passa o Luhn).
  • Templates: o template é a mensagem com dígitos → <n>, UUID → <uuid>, hexadecimal → <hex>, IP → <ip> e email → <email>. Nomes de pessoas e o resto do texto ficam como estão: «Pagamento recusado para Maria Santos» fica igual. Com amostras reais nos prompts (o modo por omissão), o diagnóstico de causa-raiz manda os templates ao Claude, como as amostras das colunas. Com amostras sintéticas, a IA recebe só as contagens, sem o texto.
  • Regras: as regras de streams aplicam-se como num tópico (max_malformed_pct, max_event_age, required_fields). O max_event_age e a frescura do dataset usam o evento mais recente das linhas lidas. Num ficheiro maior do que o teto de leitura, são as primeiras linhas, e um ficheiro que cresce por baixo parece mais antigo do que é (o mesmo acontece num CSV). O onboarding sugere max_malformed_pct a 1%. schema_conformity não se aplica: um ficheiro não tem schema registado.
  • Dados pessoais: com mensagens com dados pessoais, o scan abre um issue de monitor automático log_pii (aviso) só com as contagens por tipo. Marcado «ignorado», não volta a abrir nesse dataset.
  • UI: o detalhe do dataset mostra o painel «Ficheiro de logs» com as métricas, os níveis em barras e a tabela de templates.

Documentos no mesmo bucket: ficheiros PDF, DOCX, TXT, MD e HTML sob o prefixo não são tabelas. Entram pela análise de documentos (botão Documentos, UNSTR), com um teto de 50 MB por documento.

PDF digitalizados (OCR local, cartão #418): um PDF sem camada de texto passa pelo OCR no worker (Tesseract 5 da imagem, português, inglês e espanhol), página a página, com teto de páginas (DATAVAULT_DOCUMENTS_OCR_MAX_PAGES, 50) e prazo por documento (DATAVAULT_DOCUMENTS_OCR_TIMEOUT_SECONDS, 180 s), dentro de um orçamento por scan (DATAVAULT_DOCUMENTS_OCR_SCAN_BUDGET_SECONDS, 3 h). O documento fica marcado com o método e a confiança média, e o corpus abre o problema «N documentos lidos por OCR». O OCR é local: o documento não sai da instalação, e nada é escrito de volta no bucket. Vale para todas as fontes documentais (GCS, S3, SharePoint, Google Drive, Confluence). Sem o OCR instalado, o PDF fica fora do índice com «precisa de OCR» (método).

Imagens no mesmo bucket (cartão #182): com include_images: true na conexão (caixa «Incluir imagens na análise de documentos» no wizard; desligado por omissão), os ficheiros JPEG, PNG, WebP, GIF, TIFF e BMP entram também na análise de documentos. Vale para GCS, S3, SharePoint e Google Drive. Cada imagem é perfilada no worker só com o Pillow, sem GPU nem LLM: formato, largura × altura, ficheiro corrompido ou truncado, EXIF (se há GPS, a data de captura e a marca e modelo do aparelho, nunca o número de série), SHA-256 e um dHash de 64 bits. O corpus ganha o bloco images, com os duplicados exatos (SHA-256) e os quase duplicados (dHash a ≤ DATAVAULT_IMAGE_NEAR_DUP_HAMMING bits, 8 por omissão), os formatos, as resoluções, a % com GPS, a % corrompida e a idade pelo EXIF face à data do ficheiro. Abre os problemas image_corrupt, image_near_duplicates e image_gps_pii. Uma fotografia com coordenadas GPS é um dado pessoal de localização, por isso o problema aberto ou reconhecido bloqueia a prontidão para RAG e para treino, também na regra min_ai_readiness da política de dados. Uma imagem acima de DATAVAULT_IMAGE_MAX_PIXELS (50 MP) não é descodificada e fica «demasiado grande». Os píxeis e o texto do EXIF não vão para a BD, para o LLM nem para os logs, e a secção «Imagens» do dataset mostra só métricas, sem miniaturas. Sem o extra [images] (o Pillow, que a imagem de produção já tem pelo extra [ocr]), as imagens contam como não analisáveis e o scan termina na mesma. Imagens e documentos partilham o limit do pedido de análise (200 por omissão, até 1000). No GCS e no S3, o corte segue a ordem alfabética dos caminhos; no SharePoint e no Drive, a ordem da listagem. Para as imagens não deixarem documentos de fora, sobe o limit ou aponta o prefix a uma pasta só de imagens.

Azure Blob Storage / ADLS Gen2 (ficheiros como tabelas)

Data lake em Azure: Blob Storage e ADLS Gen2 (contas com namespace hierárquico), incluindo a conta de armazenamento de um workspace Synapse e as external locations do Databricks em Azure. O conector adls (AdlsConnector em app/infra/connectors/object_storage.py) é o código do GCS e do S3 sobre o adlfs, com o protocolo fsspec abfs. O container faz de bucket: cada Parquet, CSV ou .csv.gz sob container/prefix é uma tabela. Ficheiros de eventos e logs, documentos (PDF, DOCX, TXT, MD, HTML), OCR, imagens (include_images) e limitações seguem a secção do GCS e do S3.

Uma credencial, sempre explícita (A2). A conexão leva exatamente uma destas: a chave da conta (account_key), um token SAS (sas_token), um service principal (tenant_id + client_id + client_secret) ou anonymous: true para containers públicos. Sem nenhuma, ou com duas, a criação é recusada com 422. O BeryX nunca usa as credenciais do servidor. O adlfs recebe sempre anon=True, por isso sem credencial fica anónimo e nunca recorre ao DefaultAzureCredential (identidade gerida, Azure CLI, variáveis de ambiente). O conector recusa ligar se o servidor tiver definida uma variável AZURE_STORAGE_* que o adlfs leria no lugar da credencial da conexão, como a AZURE_STORAGE_CONNECTION_STRING. As mensagens de erro dizem só o nome dos campos e das variáveis, nunca o valor.

Service principal (recomendado) — do lado do cliente:

  1. No Entra ID: App registrations → New registration (ex.: datavault-reader) e, em Certificates & secrets, um client secret com data de expiração.
  2. No container: Access Control (IAM) → Add role assignment → Storage Blob Data Reader para a app. O papel lista e lê blobs e não escreve nada. Atribuído no container, e não na conta, a app não vê os outros containers.
  3. No wizard: a conta de armazenamento (account_name, a parte antes de .blob.core.windows.net), o container, o prefix opcional e, em «Service principal (Entra ID)», o ID do tenant, o ID da aplicação e o secret.

Token SAS: gera-o no container (Shared access tokens) só com as permissões Read e List e com data de expiração, e cola o token (começa por sv= e inclui sig=), não o URL. Quando expira, o teste de ligação e os scans falham até colares outro.

Chave da conta: funciona, mas dá à chave acesso total à conta, escrita incluída. O conector só lê (find, info e open('rb')); o service principal ou o SAS limitam o que uma credencial vazada permite.

Container público: «Acesso anónimo», para containers com o nível de acesso Container. O preset «Feriados públicos — Azure Open Datasets» usa-o.

Nuvens soberanas: endpoint_suffix aceita core.windows.net (por omissão), core.usgovcloudapi.net (Azure US Government) e core.chinacloudapi.cn (Azure China, 21Vianet), e recusa qualquer outro. O host é sempre <account_name>.blob.<endpoint_suffix>, e o service principal pede o token ao Entra ID da mesma nuvem.

Azurite (desenvolvimento e CI): endpoint_url (ex.: http://127.0.0.1:10000/devstoreaccount1) aponta o conector ao emulador. Só é aceite com DATAVAULT_ALLOW_PRIVATE_NETWORKS=true; em produção é recusado com 422. Com endpoint_url, a credencial é a chave, o SAS ou o acesso anónimo.

Campos: account_name, container, prefix?, max_files? (200 por omissão), include_images?, endpoint_suffix? e uma credencial. protocol não é configurável: abfs, abfss e az são ignorados (o conector fala sempre abfs) e qualquer outro valor é recusado.

Instalação: pip install "datavault-backend[adls]" (o adlfs, que traz o azure-storage-blob e o azure-identity, mais o pandas e o pyarrow). Vai na imagem de produção. Sem o extra, o conector responde com a instrução pip.

OneLake e Synapse: o OneLake do Microsoft Fabric fala a API do ADLS Gen2 num host próprio (onelake.dfs.fabric.microsoft.com), fora das nuvens que este conector aceita. Para um Lakehouse ou Warehouse do Fabric, usa o conector fabric. Num workspace Synapse, aponta o adls à conta de armazenamento do workspace e ao sistema de ficheiros onde estão os ficheiros; as tabelas dos SQL pools leem-se pelo mssql.

Rede: uma conta com firewall (Networking → Enabled from selected virtual networks and IP addresses) tem de aceitar o IP de saída do BeryX (ver o topo deste guia).

SFTP (file drops de parceiros e bancos)

Bancos, parceiros logísticos, seguradoras e redes EDI entregam ficheiros numa pasta SFTP. O conector sftp (SftpConnector em app/infra/connectors/sftp.py) lê essa pasta com o código do GCS e do S3, sobre o protocolo fsspec sftp (paramiko). A pasta root_path faz de bucket: cada Parquet, CSV ou .csv.gz sob root_path/prefix é uma tabela, tal como os ficheiros de eventos e logs. Os PDF, DOCX, TXT, MD e HTML entram na análise de documentos. Leitura, OCR, imagens (include_images) e limitações seguem a secção do GCS e do S3.

«O ficheiro de hoje não chegou». A data de alteração de cada ficheiro (o mtime do stat SFTP) é a última modificação que os monitores automáticos guardam em cada scan. Quando o parceiro substitui o mesmo ficheiro a cada entrega (ex.: pagamentos.csv), o monitor de frescura aprende a cadência das entregas e abre um problema quando a seguinte se atrasa. Um ficheiro com a data no nome (pagamentos_20261009.csv) é um dataset novo a cada entrega, com uma só data, e esse monitor não vê o atraso.

Chave do servidor fixada. host_key_fingerprint é o fingerprint SHA256 da chave do servidor, no formato que o OpenSSH imprime:

ssh-keyscan -t ed25519 sftp.parceiro.example | ssh-keygen -lf -
# 256 SHA256:… sftp.parceiro.example (ED25519)

Confirma o valor com o parceiro por outro canal: o ssh-keyscan pergunta ao próprio servidor e não prova que é o certo. O wizard aceita a linha inteira ou só o SHA256:…. O conector compara-o com a chave que o servidor apresenta, antes de autenticar. Se não coincidir, a ligação cai sem enviar a password nem ler dados, e o teste de ligação diz o tipo de chave que o servidor apresentou. O paramiko negoceia primeiro a chave Ed25519; num servidor sem ela, usa -t ecdsa ou -t rsa. Se o parceiro só te der o fingerprint RSA ou ECDSA de um servidor que também tem Ed25519, cola a linha inteira do ssh-keygen -lf, que acaba no tipo (ex.: (RSA)): o conector negoceia então só as chaves desse tipo. Em produção (DATAVAULT_ENV=prod) o campo é obrigatório e a criação sem ele é recusada com 422. Fora de produção, uma conexão criada pela API sem o campo aceita a chave sem verificação, e o teste de ligação mostra o fingerprint que o servidor apresentou; o wizard pede-o sempre. O formato MD5 não é aceite.

Só a credencial da conexão. A conexão leva password ou a chave privada em PEM (private_key_pem, com passphrase se estiver cifrada), nunca as duas. Cola o conteúdo da chave, que começa por -----BEGIN: um caminho de ficheiro é recusado com 422. O conector nunca lê o known_hosts, as chaves nem o agente SSH do servidor BeryX. Aceita chaves Ed25519, ECDSA e RSA. As mensagens de erro dizem só o nome dos campos.

Só leitura, dentro de root_path. O conector lista pastas, faz stat e abre ficheiros em rb. Escrever, apagar, renomear, criar pastas e descarregar para o disco do servidor BeryX estão bloqueados no próprio filesystem do conector. root_path é um caminho absoluto (ex.: /outbound) e prefix uma subpasta dele, os dois sem ... Cada pasta listada e cada ficheiro lido ou consultado (stat) tem de estar dentro de root_path, também depois de o servidor resolver os links: um prefix que seja um link para fora é recusado. Os links simbólicos não entram na listagem nem são abertos.

Do lado do parceiro (ou da tua equipa):

  1. Um utilizador só para o BeryX, preso à pasta das entregas e só com leitura. No OpenSSH: ChrootDirectory para a pasta e ForceCommand internal-sftp -R (o -R põe o servidor SFTP em só leitura). Num serviço SFTP gerido, uma política só de leitura para esse utilizador.
  2. Uma credencial para esse utilizador. Com chave, gera um par só para isto (ssh-keygen -t ed25519 -f datavault_sftp), põe o .pub no authorized_keys do utilizador e cola a privada no wizard.
  3. O fingerprint da chave do servidor (acima).
  4. Se o servidor filtra por IP, autoriza o IP de saída do BeryX (ver o topo deste guia).

Rede: o host é só o nome ou o IP, sem sftp://, porta nem caminho, e a porta é um inteiro (22 por omissão). A guarda anti-SSRF confere o nome e cada IP resolvido antes de abrir o socket, e o paramiko liga ao IP que passou a guarda. Em produção, redes privadas e loopback são recusadas; o endpoint de metadata da cloud é recusado sempre.

Campos: host, port?, username, password ou private_key_pem (+ passphrase?), host_key_fingerprint, root_path, prefix?, max_files? (200 por omissão), include_images?. protocol não é configurável: só sftp; o alias ssh do fsspec e qualquer outro valor são recusados.

Instalação: pip install "datavault-backend[sftp]" (o paramiko, mais o pandas e o pyarrow). Vai na imagem de produção. Sem o extra, o conector responde com a instrução pip.

FTPS e FTP não são suportados. O protocolo ftp do fsspec não faz TLS de forma fiável, e o FTP sem TLS manda a password em claro. A um parceiro que só tem FTPS, pede SFTP ou um bucket.

PostgreSQL / MySQL / SQLite

  • PostgreSQL (postgres, extra postgres): cria um utilizador read-only (GRANT CONNECT ON DATABASE, GRANT USAGE ON SCHEMA, GRANT SELECT ON ALL TABLES IN SCHEMA ...). Campos: host, port, database, user, password, ou url completa.
  • MySQL/MariaDB (mysql, extra mysql): GRANT SELECT ON <db>.* TO 'datavault'@'%';. Campos iguais ao Postgres.
  • SQLite (sqlite, incluído): path para o ficheiro .db — pensado para testes e demos, não para produção.

Réplica de leitura (Postgres, MySQL e SQL Server): replica_host e replica_port (opcional) põem todas as ligações do BeryX na réplica, com as mesmas credenciais e sem fallback para o primário. Detalhes em DBA.md.

Mensagem do teste de ligação (cartão #590). Nos conectores SQL (Postgres, MySQL/MariaDB, SQLite, Snowflake, BigQuery, Databricks, Fabric, SQL Server e Oracle), o erro do driver passa por um filtro antes de chegar ao resultado do teste e ao último erro da conexão, que todos os membros do workspace veem. Saem como *** os valores da password, dos tokens e dos segredos do config, o url inteiro e a password do url, tanto descodificada como percent-encoded. Nos pares como password=… ou PWD=… sai o valor, um DSN ://user:password@ perde a password e um bloco PEM sai inteiro. O host e o utilizador ficam, porque dizem onde a ligação falhou. Prova: backend/tests/test_connection_message_redaction.py.

Erros de leitura (cartão #612). O mesmo filtro corre nos erros que os conectores SQL devolvem ao listar tabelas e colunas, ler a estrutura de uma tabela, ler e contar linhas, calcular checksums, avaliar uma regra SQL ou o ensaio de uma remediação e ler a telemetria FinOps do Snowflake, do Databricks e do BigQuery. Estes erros chegam ao resultado da descoberta de tabelas, ao erro do scan e ao resultado das regras. Nas regras, a 1.ª linha do erro passa pelo filtro antes da máscara dos literais, que corta numa aspa por fechar e aos 300 caracteres. Prova: backend/tests/test_sql_read_error_redaction.py, com uma guarda que chumba um texto de exceção fora do filtro num método de um conector SQL.

TLS (Postgres e MySQL/MariaDB)

Desde o cartão #449, cada conexão Postgres ou MySQL tem um tls_mode:

tls_mode O que faz Como se escolhe
verify-full Cifra a ligação e verifica o certificado e o hostname do servidor. Sem TLS, ou com um certificado que não confere, a ligação não abre. Default das conexões novas.
require Cifra a ligação, mas aceita qualquer certificado. Um intermediário pode fazer-se passar pela fonte. Confirmação explícita (tls_downgrade_confirmed: true na API, um diálogo no wizard). Fica na auditoria como connection_tls_downgraded, só com o nome, o tipo e o modo.
disable Liga sem TLS: credenciais e dados viajam em claro. A mesma confirmação, e também fica na auditoria.
  • CA do servidor (ssl_ca_pem). Sem ela, o certificado tem de vir de uma CA pública (o bundle da Mozilla, via certifi). O RDS, o Cloud SQL, o Azure Database e as CAs internas são privadas: cola o certificado da CA em PEM (texto que começa por -----BEGIN CERTIFICATE-----). Um bundle com várias CAs também serve, até 256 KB, que chega para o bundle global do RDS. O PEM é cifrado com o resto do config e nunca aparece na API, nos logs nem na auditoria. Só se aceita com verify-full. Caminhos de ficheiro e chaves privadas são recusados com 422.
  • Drivers. No Postgres, a libpq recebe sslmode e sslrootcert. A CA própria vai para um ficheiro temporário 0600, que vive tanto quanto o engine e é apagado no fim. Não se usa o sslrootcert=system da libpq: o psycopg2-binary da imagem embute uma libpq 17 com um OpenSSL que procura as CAs do sistema numa pasta da build do wheel que não existe. No MySQL, o PyMySQL (≥ 1.2) recebe um SSLContext com a CA em memória e recusa ligar se o servidor não anunciar TLS. Com tls_mode, a ligação usa sempre o driver oficial do conector (postgresql:// ou postgresql+psycopg2://; mysql+pymysql:// ou mariadb+pymysql://).
  • url completo. O sslmode do URL só é aceite se for igual ao tls_mode. allow, prefer e verify-ca são recusados: os dois primeiros caem para claro sem avisar e o terceiro não confere o hostname. sslrootcert, sslcert e sslkey continuam fora da allow-list.
  • Réplica de leitura. Com verify-full, o certificado da réplica tem de ser válido para o replica_host.
  • MongoDB (#211). As conexões MongoDB têm o mesmo tls_mode, o mesmo ssl_ca_pem e a mesma confirmação. Não há conexões MongoDB anteriores à regra: sem tls_mode, ligam em verify-full. O pymongo recebe tls e tlsCAFile (o bundle do certifi ou a CA própria num ficheiro 0600) e, em require, tlsAllowInvalidCertificates e tlsAllowInvalidHostnames. O tls do uri tem de coincidir com o tls_mode. Detalhes na secção do MongoDB. Prova: backend/tests/test_mongodb_tls.py (opções do driver, API e auditoria) e backend/tests/test_mongodb_tls_docker.py, contra o mongodb-tls do compose (perfil tls, só com TLS e o certificado do postgres-tls).
  • Conexões criadas antes do #449. Não têm tls_mode e mantêm o comportamento antigo: a libpq em prefer e o PyMySQL em modo «preferred», que cifram se o servidor deixar, sem verificar, e podem cair para claro. A app mostra-as como «TLS não verificado», e a API devolve tls_mode: null e tls_verified: false. Para as verificar, recria-se a conexão. Mudar o default destas conexões é decisão do owner (ENCRIPTACAO_RESIDENCIA.md §8).
  • Fontes públicas da galeria. O Postgres do RNAcentral e o MySQL do Ensembl não aceitam TLS. O MySQL do Rfam usa o certificado autoassinado que o MySQL gera. Os presets trazem disable e require, e a app pede a mesma confirmação antes de os ligar.
  • Prova. backend/tests/test_connector_tls.py cobre os kwargs por modo e o PyMySQL com TLS real contra um servidor MySQL falso. backend/tests/test_postgres_tls_docker.py corre contra um Postgres de certificado autoassinado: o serviço postgres-tls do docker-compose.connectors.yml, no tier docker da matriz.

Garantias mecânicas (além dos GRANTs): as sessões Postgres abrem com default_transaction_read_only=on e o SQLite com mode=ro. Uma url completa tem de usar o dialeto do próprio conector (ex.: postgresql://… num conector postgres) e só aceita os query params da allow-list do dialeto (qualquer outro é recusado com 422, a apontar para os campos estruturados; a regra também é aplicada em runtime a conexões já gravadas):

Dialeto Query params permitidos
postgresql sslmode (só verify-full, require ou disable, igual ao tls_mode), application_name, connect_timeout
mysql / mariadb charset, connect_timeout, read_timeout
sqlite mode=ro, uri=true
snowflake warehouse, role, application, login_timeout, network_timeout
bigquery location, arraysize, list_tables_page_size, maximum_bytes_billed
databricks http_path, catalog, schema
mssql (Fabric, SQL Server/Azure SQL/Synapse) driver (ODBC Driver 17/18 for SQL Server), Authentication (ActiveDirectoryServicePrincipal/ActiveDirectoryPassword), Encrypt (ativo), TrustServerCertificate=no, ApplicationIntent=ReadOnly (só este valor), connect_timeout
oracle service_name, sid, connect_timeout

Ficam de fora, entre outros, host/hostaddr, options, local_infile, init_command, odbc_connect, credentials_path e authenticator.

Destino revalidado ao ligar (cartão #339): cada host e URL do config volta à guarda anti-SSRF antes de abrir a ligação, uma vez por engine (BaseConnector.assert_destination_allowed), e não só ao gravar a conexão. Uma conexão gravada antes de uma regra nova, ou um nome que entretanto passou a resolver para a rede interna (DNS rebinding), é recusada antes de qualquer ligação TCP. O nome é resolvido uma vez e, se algum dos IPs (IPv4 ou IPv6) for interno, a ligação é recusada. O scan marca o dataset como falhado com a razão «Destino de conexão não permitido neste ambiente: '' ()», sem o IP. Onde o driver o permite, o conector liga só aos IPs validados, pela ordem do resolver (se o primeiro recusar a ligação, tenta o seguinte), e o driver não volta a resolver o nome:

Dialeto Destino
PostgreSQL (psycopg2, psycopg) os IPs validados vão em hostaddr e o host fica com o nome, repetido um por IP: a libpq liga aos IPs e verifica o certificado contra o nome
MySQL/MariaDB (pymysql) o socket TCP abre-se para o primeiro IP validado que aceite a ligação e o PyMySQL fica com o hostname, contra o qual o TLS continua a verificar o certificado
SQL Server, Fabric, Oracle, Snowflake, BigQuery (url), Databricks e outros drivers do Postgres/MySQL só a revalidação: o driver resolve o nome outra vez ao ligar
S3 (endpoint_url), Azure (endpoint_url do Azurite) e Dynamics 365 (org_url) revalidação ao abrir o filesystem ou o cliente (o Dynamics fixa o IP em cada pedido pelo ReadOnlyHttpClient)
MongoDB (pymongo) o driver resolve cada nome pela guarda (netguard.guarded_getaddrinfo, cartão #564) e liga aos endereços validados nessa resolução; ver MongoDB

Com as redes privadas bloqueadas (produção), um host que não resolve é recusado (netguard.check_host, cartão #564), na criação da conexão e em cada revalidação: aceitá-lo deixava o driver resolvê-lo mais tarde, sem guarda, e o nome podia entretanto apontar para a rede interna. No url do Snowflake e do BigQuery, o «host» é a conta e o projeto, e não um nome de rede: continua a passar pela guarda, mas não tem de resolver. Os drivers com IP fixado (psycopg2, psycopg e pymysql) recusam também um caminho (socket Unix local) e uma lista de hosts separados por vírgula: a guarda não teria validado o destino a que o driver ia ligar. Com DATAVAULT_ALLOW_PRIVATE_NETWORKS=true (desenvolvimento), o socket local e um nome que não resolve ligam como antes, sem IP fixado.

Desvio-padrão: calculado pela função nativa STDDEV_POP (Postgres, MySQL/MariaDB, BigQuery, Snowflake, Databricks, Oracle) ou STDEVP (SQL Server, Azure SQL, Synapse, Fabric). No SQLite é um agregado em Python registado em cada ligação: exato em inteiros e Welford no resto, na mesma query dos outros agregados. A fórmula antiga E[x²] − E[x]² em vírgula flutuante cancelava-se em colunas de grande magnitude e devolvia 0,0 numa coluna epoch-millis com dispersão 100 (o valor certo é 28,9). Fica só como fallback de dialetos desconhecidos. Uma média ou desvio que a fonte devolva como NaN/±Inf (ex.: float8 com 'NaN' no Postgres) fica «desconhecido» no perfil.

Tempo máximo por query e repetições

DATAVAULT_CONNECTOR_STATEMENT_TIMEOUT_S (120 s) é o tempo máximo de cada query em todas as fontes SQL. A fonte cancela a query no servidor, e um agregado aberrante não deixa o warehouse ligado até ao teto do fornecedor (no Snowflake, 2 dias). Cada dialeto recebe-o pela sua via (app/infra/connectors/timeouts.py, cartão #346):

Fonte Como o teto chega à fonte
PostgreSQL -c statement_timeout nas options da ligação
MySQL SET SESSION max_execution_time (ms) em cada ligação nova e o read_timeout do PyMySQL 30 s acima do teto mais longo das sessões
MariaDB SET SESSION max_statement_time (s) em cada ligação nova (o MariaDB não tem max_execution_time) e o mesmo read_timeout
Snowflake STATEMENT_TIMEOUT_IN_SECONDS nos session_parameters, ao lado do QUERY_TAG
Databricks SET STATEMENT_TIMEOUT em cada sessão nova
BigQuery job_timeout_ms no QueryJobConfig por omissão do cliente, junto ao maximum_bytes_billed
SQL Server (pymssql) timeout nos connect_args
SQL Server (pyodbc) e Fabric Connection.timeout de cada ligação. O timeout do connect do pyodbc é o de login
Oracle call_timeout (ms) do oracledb em cada ligação nova. Conta por ida e volta à base; ao expirar, o driver interrompe a chamada no servidor (DPY-4024)

Com 0, nenhum dialeto recebe teto. As regras SQL e o ensaio da remediação mudam o teto da sessão só durante a sua query e repõem o da ligação no fim. Um MySQL ou Databricks que recuse o SET (versão antiga) liga na mesma, com o teto do servidor, e deixa connector_statement_timeout_unsupported no log.

Repetições. Um erro passageiro da fonte deixava o dataset falhado nesse scan e um buraco no histórico de drift e anomalias. Agora o perfil, o perfil incremental, as linhas, os metadados e a contagem de uma tabela repetem-se até DATAVAULT_CONNECTOR_RETRY_ATTEMPTS vezes (3), com esperas de até 2 s e 4 s (exponenciais, no máximo 30 s, com jitter). Só se repete o que é passageiro em cada dialeto (app/infra/connectors/retry.py):

Fonte Repete
PostgreSQL SQLSTATE 08xxx, 57P01/57P02/57P03 (failover), 53300 (sem ligações livres), 40001/40P01 (conflito numa réplica, deadlock)
MySQL/MariaDB 2003, 2006, 2013 (salvo o read_timeout), 1040, 1213
BigQuery rateLimitExceeded, backendError, internalError, HTTP 429/500/502/503
Snowflake 390114 (token da sessão expirado) e warehouse suspenso ou a retomar
Databricks warehouse a arrancar, TEMPORARILY_UNAVAILABLE
SQL Server/Fabric SQLSTATE 08xxx do ODBC e os erros passageiros do Azure SQL (1205, 40197, 40501, 40613, 49918–49920)
Oracle só a ligação que o SQLAlchemy dá por perdida: ORA-00028, ORA-03113, ORA-03114, ORA-03135, ORA-01033, ORA-02396, DPY-1001 e DPY-4011
Todas ligação reposta ou recusada pelo outro lado

Nunca se repetem os timeouts (a query cancelada por tempo custaria outra vez o mesmo), os erros de autenticação e de permissão, as recusas do BeryX (teto de bytes, destino recusado pela guarda anti-SSRF) nem um erro de configuração. O SQLite nunca repete. As repetições de um dataset somam no máximo DATAVAULT_CONNECTOR_RETRY_BUDGET_S (60 s, esperas e tentativas repetidas): esgotado o orçamento, o dataset falha com o último erro. Cada repetição fica no log connector_retry com o correlation_id do scan, a tentativa e só o nome da classe do erro (a mensagem do driver pode trazer dados). Quando houve repetições, o resumo do scan guarda o total em retries. Um erro resolvido por uma repetição não conta para o circuit breaker da conexão.

Tabelas largas

Cada agregado é um scan à coluna, e num warehouse é faturado. Por isso uma tabela com mais de DATAVAULT_PROFILE_MAX_COLUMNS colunas (120) perfila só 120. Até ao cartão #289 eram as primeiras 120 e o catálogo do dataset ficava só com elas: numa tabela SAP/CRM de 300 colunas, 180 desapareciam do catálogo e o monitor de mudanças de schema (SCHEMACHG) ficava cego a elas. Agora:

  • o catálogo guarda todas as colunas, lidas na mesma passagem do perfil (sem uma segunda ida à fonte);
  • as colunas perfiladas são escolhidas por prioridade: chaves (PK, id, *_id), colunas de data/hora, nomes com dados pessoais (email, nif, iban, telefone, …) e depois a ordem do esquema. A escolha é determinística e estável entre scans, e é a mesma no perfil incremental. Vive em app/infra/connectors/profiling.py;
  • o perfil da tabela regista profiled_columns, skipped_columns e column_count (o catálogo). A UI mostra «120 de 300 colunas perfiladas» e marca as restantes como «não perfilada»;
  • o diff de schema compara catálogos completos. O issue schema_change de uma tabela larga diz quantas colunas foram perfiladas e que o diff cobre o catálogo todo. No primeiro scan depois desta versão o perfil anterior ainda não tem a fotografia do catálogo, e o diff compara só as colunas que esse perfil via. Assim não aparecem ~180 colunas «novas» falsas, e a nota do issue explica-o.

Amostra de linhas por dialeto

As regras que precisam de linhas (pattern, required_fields) e os duplicados leem até 5 000 linhas por tabela (ROW_FETCH_LIMIT, sempre abaixo da amostragem máxima da fonte); a qualidade de rótulos lê com o mesmo método, até ao seu próprio teto. Desde o cartão #317 essas linhas são uma amostra escolhida pela própria fonte, com uma semente fixa por dataset — a mesma de scan para scan, para os clusters de duplicados e as violações de padrão não abrirem e fecharem sem os dados mudarem. Só SELECTs:

Fonte Como amostra Repete com a mesma semente
PostgreSQL TABLESAMPLE BERNOULLI (p) REPEATABLE (s); acima de DATAVAULT_SAMPLE_FULL_SCAN_MAX_ROWS (25 M), TABLESAMPLE SYSTEM (blocos) sim
BigQuery TABLESAMPLE SYSTEM (p PERCENT) — só os blocos amostrados são faturados não (o BigQuery não tem REPEATABLE)
Snowflake SAMPLE SYSTEM (p) SEED (s) sim
Databricks TABLESAMPLE (p PERCENT) REPEATABLE (s) sim
MySQL/MariaDB, SQLite, SQL Server/Azure SQL/Synapse/Fabric WHERE hash(chave primária#s) mod 10⁶ < k, ordenado pelo hash (até ao mesmo teto de 25 M) sim
sem chave primária, vista, ou amostra nativa recusada as primeiras linhas por ordem da 1.ª coluna (sem ordem acima do teto e nos warehouses) só com ordem
CSV, GCS, S3 uma linha em cada k das que o perfil lê sim

p sai do row_count do perfil (~1,5× o limite, cortado em Python pelo hash das linhas); uma vista ou foreign table no PostgreSQL recusa TABLESAMPLE e a amostra passa às primeiras linhas, com o evento sample_native_fallback no log do worker. O perfil grava o método em metrics_json["sample"] (method, rows, fraction, seed, deterministic) e a app mostra-o num selo junto às colunas; o issue de duplicados e os issues das regras de padrão e de validade dizem-no no texto. Um scan manual com «Nova amostra» (resample no POST /scans) usa outra semente só nesse scan.

Os sample_values do perfil (até 20 por coluna, para os tipos semânticos e a deteção de PII) saem de uma amostra de 1 000 linhas pelo mesmo método, em vez de um SELECT DISTINCT … LIMIT 20 por coluna: o perfil SQL faz 2 + ⌈colunas/10⌉ queries por tabela, onde antes fazia 1 + ⌈colunas/10⌉ + colunas (numa tabela de 120 colunas, 120 queries a mais; no BigQuery, 120 leituras faturadas). Há colunas que voltam a pedir os seus valores à parte: as quase todas NULL e as de poucas categorias que a amostra não viu todas, para a proposta de valores aceites não perder uma categoria rara. Com a guarda de custo do BigQuery degradada, nenhuma volta. No perfil incremental a amostra fica na janela nova e nunca é TABLESAMPLE, que corre antes do WHERE e leria a tabela inteira: vai pelo hash da chave ou pelas primeiras linhas pela coluna temporal. O hash de 32 bits por dialeto (hashtext, CRC32, dv_hash, FARM_FINGERPRINT, HASH, xxhash64, HASHBYTES) serve também os checksums do data-diff e os sketches HLL do perfil incremental, que passam a existir no BigQuery, Snowflake, Databricks e SQL Server. O texto que o hash lê é fiel ao valor. No BigQuery, ARRAY, STRUCT, JSON e BYTES vão por TO_JSON_STRING. No SQL Server e no Fabric, datetime, float, money e binários vão por CONVERT com estilo explícito, porque o CAST perdia segundos e dígitos. Uma coluna que a fonte não consegue escrever em texto fica sem checksum e o data-diff compara-a pela amostra. Detalhes e limitações em Métodos › Amostragem.

Chaves, partições e tipo do objeto

Desde o cartão #347 o BeryX lê o que a fonte declara sobre cada objeto. Lê só o catálogo, nunca dados, na descoberta e em cada scan. Uma parte que a conta não possa ler fica vazia e o scan segue.

Fonte Tipo do objeto Chaves primárias e estrangeiras Partições e clustering
PostgreSQL tabela (também a particionada), view, materialized view e foreign table (externa), pelo inspector do SQLAlchemy inspector —
MySQL/MariaDB, SQLite, SQL Server, Fabric tabela e view, pelo inspector inspector —
BigQuery INFORMATION_SCHEMA.TABLES.table_type TABLE_CONSTRAINTS, KEY_COLUMN_USAGE e CONSTRAINT_COLUMN_USAGE COLUMNS.is_partitioning_column e clustering_ordinal_position
Snowflake INFORMATION_SCHEMA.TABLES.TABLE_TYPE inspector CLUSTERING_KEY (só nomes simples: uma expressão como TO_DATE(ts) fica de fora)
Databricks tabela e view, pelo inspector inspector DESCRIBE DETAIL (partitionColumns, clusteringColumns)

No BigQuery, no Snowflake e no Databricks as chaves são informativas: a fonte não as impõe. O BeryX usa-as como chave, nunca como prova de unicidade (a regra unique mede-a).

Permissões. Nenhuma nova. No BigQuery as vistas de INFORMATION_SCHEMA pedem bigquery.tables.get e bigquery.tables.list, já incluídas em BigQuery Data Viewer. São até cinco consultas por dataset e scan (MATERIALIZED_VIEWS só se o dataset tiver materialized views, CONSTRAINT_COLUMN_USAGE só se tiver chaves estrangeiras), cada uma faturada no mínimo a 10 MB.

Onde aparece. O catálogo mostra o tipo ao lado do nome (views, materialized views, tabelas externas) e as marcas «PK», «partição» e «clustering» nas colunas. A API devolve kind em cada dataset e primary_key, partition e clustering em cada coluna do detalhe. A regra unique sugerida no primeiro scan fica na chave primária declarada; os nomes (id, *_id) ficam como recurso. Uma chave estrangeira declarada vira uma aresta declared na linhagem, antes da convenção por nome. O data-diff usa a chave primária da base quando o pedido não traz key_columns, e a sugestão de chaves do matching põe primeiro a chave primária que as duas fontes declaram com os mesmos nomes.

Views e tabelas externas. Cada query a uma view executa o SQL dela por inteiro, e a uma tabela externa lê os ficheiros. Por omissão perfilam em basic. A query do COUNT(*) corre sobre o objeto inteiro e traz também o mínimo e o máximo das colunas de data e hora: a contagem fica exata, porque os monitores de volume a comparam entre scans, e a frescura vem da linha mais recente, não das primeiras lidas. Os outros agregados e as amostras vêm só das primeiras 100 000 linhas, sem COUNT(DISTINCT), e acima disso o perfil diz sampled. Numa tabela só com inserções, as primeiras linhas são quase sempre as mais antigas: os nulos, os vazios, os sentinelas, os intervalos numéricos e as amostras das linhas novas ficam por medir. Quando uma view grande passa do perfil completo ao limitado, as métricas dela podem saltar uma vez no scan seguinte. No BigQuery o LIMIT não reduz os bytes faturados de uma view, e o teto maximum_bytes_billed da conexão continua a recusar a query que o passar. Para o perfil completo, um admin escolhe a profundidade standard ou deep do dataset em Datasets → Operações em massa; basic mantém o perfil limitado.

Materialized views. Perfilam como tabelas. Nos monitores automáticos, a frescura (last_modified) é o último refresh: MATERIALIZED_VIEWS.last_refresh_time no BigQuery e refreshed_on de SHOW MATERIALIZED VIEWS no Snowflake.

Corpus adversarial de profiling

Os casos abaixo não são exóticos, são o dia-a-dia de um ERP português ou espanhol. O corpus existe para podermos afirmar «confiem nos números» com prova. Vive em backend/tests/fixtures/adversarial/ e tem duas partes: um gerador (build_corpus.py, com os dados de cada caso e o tipo SQL declarado de cada coluna) e as expectativas, um YAML por caso em cases/. O formato está no README.md da pasta. O teste backend/tests/test_adversarial_corpus.py gera o corpus num diretório temporário, perfila cada caso pelo conector CSV (nas vias pandas e stdlib, que têm de concordar) e pelo SQLite, e compara com o YAML. As tolerâncias são explícitas: rel=1e-9 por omissão e abs=1e-6 no desvio-padrão epoch. Cada perfil do corpus tem também de gravar-se em JSON estrito.

Caso O que prova
bom_utf8, bom_utf16 BOM do Excel (UTF-8 e UTF-16 LE): nada de  no nome da primeira coluna
ponto_e_virgula_cp1252, tab_latin1, latin1_bytes_c1 ;, tab e | + cp1252/latin-1; o perfil diz delimiter/encoding
aspas_e_quebras, uma_coluna aspas, delimitadores e quebras de linha dentro de campos; Sniffer sem veredicto
zeros_a_esquerda 08001, 000123, 0034… ficam texto; "0" é inteiro
decimais_pt_e_datas_pt, sentinelas, vazio_vs_null 1.234,56, 31/12/2024, N/A, -, 9999-12-31; vazio vs NULL
nan_inf NaN/±Inf: nan_count, fora da média e das amostras, JSON sem NaN
fusos_mistos, dst_lisboa, datas_futuras naive vs aware (o perfil CSV rebentava), 01:30 duplicado a 2026-10-25 em Lisboa, datas em 2099
decimal_38_10, bigint_extremos, epoch_millis precisão numérica; stddev bate com statistics.pstdev a 1e-6
nfc_nfd «José» composto e decomposto
tabela_vazia, uma_linha, coluna_toda_null limites: 0 linhas, 1 linha (desvio 0), coluna só com NULL
tabela_larga_300 300 colunas: catálogo com 300, 120 perfiladas pela prioridade
identificadores_reservados, sem_pk, vista Order Details, colunas select/order/group, tabela sem PK, uma vista

Limitações registadas (e não corrigidas). Cada YAML diz em known_limitations o que o comportamento atual ainda não resolve:

  • decimais PT e datas dd/mm/aaaa ficam texto;
  • 9999-12-31 e datas futuras entram no máximo da coluna e no latest_timestamp que o conector devolve (a frescura já não os usa: desde o cartão #292 o domínio põe essas colunas fora da escolha da coluna de frescura e levanta o alerta future_timestamps);
  • NFC e NFD contam como valores distintos;
  • as duas 01:30 da mudança de hora são o mesmo valor sem fuso;
  • DECIMAL(38,10) perde precisão em float e no REAL do SQLite;
  • o SQLite guarda NaN como NULL, por isso o nan_count só é medido nos ficheiros.

Primeiro scan depois desta versão (transição). As colunas de ficheiros (CSV, GCS, S3) cujo tipo estava errado mudam uma vez: integer para string/text nos códigos com zeros à esquerda ou com sentinelas, e text para datetime nas datas ISO dos buckets. O SCHEMACHG regista-o como type_changed. Não é suprimido, porque o tipo anterior estava errado e o issue documenta a correção.

Como o #207 corre o mesmo corpus contra fontes reais. A matriz de validação real por conector (#207) reutiliza CASES e os YAML sem os copiar:

  1. Carga: para cada caso com o alvo SQL, cria a tabela com o sql_type de cada coluna traduzido para o dialeto. Por exemplo, INTEGER → bigint/BIGINT/INT64; REAL → double precision/DOUBLE/FLOAT64; DECIMAL(38,10) → numeric(38,10)/DECIMAL(38,10)/BIGNUMERIC; TIMESTAMP → timestamp/DATETIME/DATETIME. Depois insere as mesmas linhas com parâmetros do driver, nunca por interpolação. Postgres 16 e MySQL 8 correm no docker-compose.connectors.yml do job docker da matriz; o BigQuery corre no tier sandbox, com os secrets DATAVAULT_MATRIX_BIGQUERY_*.
  2. Perfil: corre com o conector real (postgres, mysql, bigquery), via profile_table, como num scan.
  3. Comparação: o runner de test_adversarial_corpus.py já funde all com a secção do alvo. O #207 acrescenta secções postgres:, mysql: e bigquery: aos YAML, só com o que muda por dialeto: nomes de tipo, a precisão exata do numeric(38,10), o NaN que o Postgres guarda em float8, e o nan_count por SQL nativo, que é âmbito do #207. O resto (all) tem de passar tal como está.
  4. O que só uma fonte real prova: o STDDEV_POP nativo no caso epoch_millis (Postgres, MySQL, BigQuery) e o fallback do information_schema com CAST(:s AS CHAR) num MySQL real. Até esta versão o CAST(:s AS TEXT) falhava no MySQL e a reflexão caía em silêncio para []. Hoje só o texto da query está coberto por teste. Falta também a leitura de DECIMAL(38,10) sem perda.

SAP

O conector sap (cartão #212) lê o SAP S/4HANA e o SAP Gateway pelos serviços OData, v2 e v4, que o próprio SAP publica. Cada entity set do serviço é uma tabela. Assenta na base OData comum (app/infra/connectors/odata.py), a mesma do OData genérico e do Dynamics 365. A parte própria do SAP está em app/infra/connectors/sap.py.

URL do serviço (base_url): a raiz de um serviço OData, sempre em https. Cada conexão lê um serviço; para ler outro, cria outra conexão.

  • On-premise (SAP Gateway): https://<host>/sap/opu/odata/sap/API_BUSINESS_PARTNER. O último segmento é o nome técnico do serviço.
  • S/4HANA Cloud: https://<tenant>-api.s4hanacloud.ondemand.com/sap/opu/odata/sap/API_BUSINESS_PARTNER.

Autenticação (auth):

  • basic (por omissão): username e password de um utilizador de comunicação dedicado ao BeryX.
  • oauth2_client_credentials: token_url, client_id, client_secret e scope (opcional). O pedido do token vai só ao token_url, que tem de ser https e passa pela guarda anti-SSRF.
  • api_key: api_key e api_key_header (opcional; por omissão APIKey, o cabeçalho do sandbox do SAP Business Accelerator Hub). É o modo para experimentar contra o sandbox.

O modo none (sem credenciais) é recusado no sap. A password, o client_secret e a api_key nunca voltam a ser mostrados nem aparecem em logs.

Outros campos:

  • sap_client (opcional): o mandante, com três dígitos (ex.: 100). Vai como sap-client=100 em cada pedido.
  • entity_sets (opcional): lista, ou texto separado por vírgulas, dos entity sets a ler (ex.: A_BusinessPartner,A_BusinessPartnerAddress). Por omissão, todos os do $metadata, até 200.
  • sample_size (opcional): linhas da amostra do perfil, de 1 a 5000 (1000 por omissão).

Autorizações — do lado do cliente:

  1. On-premise: ativa o serviço no Gateway (transação /IWFND/MAINT_SERVICE) e dá ao utilizador de comunicação um papel com a autorização S_SERVICE desse serviço. No back-end, o papel só precisa de autorizações de visualização sobre os dados que o serviço expõe.
  2. S/4HANA Cloud: cria um communication arrangement para o cenário de comunicação que expõe o serviço, com um communication user, e usa as credenciais desse utilizador no modo basic.

Teste de ligação: o test_connection lê o $metadata e pede uma linha ($top=1) da primeira entidade. Quando falha, diz o que corrigir:

Resposta do SAP Diagnóstico
401 Utilizador e password, ou as credenciais do modo, recusados
403 Falta ao utilizador a autorização S_SERVICE do serviço
404 Serviço não ativado no Gateway (/IWFND/MAINT_SERVICE) ou URL errado
400, com sap_client Mandante (sap-client) errado

Só leitura: os pedidos ao serviço são só GET, pelo ReadOnlyHttpClient (guarda anti-SSRF e pinning de DNS). O conector nunca pede o token CSRF (X-CSRF-Token: Fetch), que o SAP só exige às escritas. Com oauth2_client_credentials, o único POST é o pedido do token, ao token_url.

Colunas, contagem e amostra:

  • As colunas e os tipos vêm do $metadata (EDMX), com os tipos Edm mapeados para os do BeryX.
  • O número de linhas vem do serviço: $inlinecount=allpages no OData v2, $count no v4 e, se a resposta não trouxer a contagem, GET <EntitySet>/$count.
  • As métricas das colunas são calculadas sobre uma amostra de sample_size linhas ($top, com $format=json), como no Dynamics: as percentagens de nulos e de distintos referem-se à amostra.
  • As datas /Date(…)/ do v2 e os Edm.DateTimeOffset passam a UTC. Os Edm.Int64 e Edm.Decimal, que o v2 manda como texto, passam a número. Vale para o perfil e para as linhas que os scans leem (odata.py: convert_value, row_value). Nas linhas, o Edm.Decimal fica Decimal exato, como nos conectores SQL.
  • A paginação segue o __next (v2) e o @odata.nextLink (v4) só quando o link tem a mesma origem (esquema, host e porta) do base_url. Um link para outra origem é recusado.
  • O conector pede as linhas em blocos de 1000 ($top). Se o serviço servir um bloco em páginas mais pequenas (o Northwind usa páginas de 20 e de 500), o conector segue os links. Quando o bloco chega completo, pede o seguinte com $skip, até ao sample_size (no perfil) ou ao limite de linhas do scan (odata.py: _collect).

Validação ao gravar (422): base_url em falta ou sem https, auth desconhecido ou none, campos da autenticação em falta, sap_client sem três dígitos, token_url sem https, api_key_header inválido, nomes inválidos em entity_sets e sample_size fora de 1 a 5000.

Instalação: nenhum extra. O conector usa só as dependências base (httpx).

Rede: em produção, a guarda anti-SSRF recusa destinos em redes privadas. Um Gateway on-premise tem de estar acessível em https a partir do IP de saída do BeryX (ver o topo deste guia).

Fora do âmbito: a ligação direta à base de dados HANA (hdbcli). O BeryX lê o SAP pelos serviços OData.

Integração com processos SAP e Salesforce

«Integra com o nosso SAP?» — a resposta curta:

  • Lemos o SAP S/4HANA e o SAP Gateway por OData, o Salesforce, o Dynamics 365 e as bases de dados dos ERP que correm em SQL Server — Primavera, PHC e Sage. Outros serviços OData v2 e v4 leem-se pelo OData genérico.
  • Nunca escrevemos no ERP nem no CRM, nem instalamos plugins dentro do SAP ou do Salesforce: seria abrir mão do acesso read-only. A correção sai em SQL/PySpark ou como Pull Request, para o teu processo aprovar e aplicar.
  • Não validamos o registo no momento em que é gravado (dentro da transação SAP ou Salesforce): medimos o que ficou gravado, no scan seguinte. Validar no ponto de entrada, com escrita, é o território das suites de MDM e de qualidade (SAP MDG, Informatica).

OData (genérico)

O conector odata (cartão #212) lê serviços OData v2 e v4 que não têm conector próprio no BeryX: serviços OData internos da empresa ou serviços públicos. Cada entity set é uma tabela. É a base do SAP (app/infra/connectors/odata.py) e comporta-se da mesma forma: só GET, colunas pelo $metadata, contagem pelo serviço, perfil sobre uma amostra e paginação só na mesma origem do base_url. O teste de ligação lê o $metadata e pede uma linha da primeira entidade.

Campos:

  • base_url (https): a raiz do serviço, onde o <base_url>/$metadata responde (ex.: https://services.odata.org/V2/Northwind/Northwind.svc).
  • auth: none por omissão, para serviços públicos sem credenciais. Os modos basic, oauth2_client_credentials e api_key levam os mesmos campos do SAP.
  • sap_client, entity_sets e sample_size: como no SAP.

Limites: só os entity sets declarados no $metadata da raiz (odata.py: parse_metadata, list_tables). Um caminho por dentro de outra entidade, como Company('…')/Customers, fica fora. O Business Central não foi validado.

Serviços públicos: com auth none, como os serviços de referência do OData.org, https://services.odata.org/V2/Northwind/Northwind.svc/ (v2) e https://services.odata.org/TripPinRESTierService/ (v4). São as fontes do odata na matriz de conectores (tier public).

Validação ao gravar (422): a mesma do SAP, exceto que o none é aceite.

Instalação: nenhum extra (httpx).

Conectores próprios: para o Dataverse do Dynamics 365 usa o dynamics, e para o SAP o sap.

API REST/JSON (genérica)

O conector rest_api (cartão #269) lê APIs e microsserviços REST que devolvem JSON e não têm conector próprio. Cada endpoint GET configurado é um dataset, perfilado sobre uma amostra (app/infra/connectors/rest_api.py). O teste de ligação pede a primeira página do primeiro endpoint.

Campos:

  • base_url (https): a raiz da API, sem query (ex.: https://api.example.com/v1).
  • auth: none por omissão; bearer com token; basic com username e password; api_key com api_key num header próprio (api_key_header, por omissão X-API-Key). A chave nunca vai na query: um segredo no URL fica nos logs dos proxies.
  • endpoints: a lista de endpoints, cada um um caminho (/users) ou um objeto com path, name?, records_path?, params?, pagination?, total_path? e contract_path?. O name é o nome do dataset; sem ele, vem do caminho.
  • records_path: onde está a lista no payload, em JSONPath simples ($.data, $.data.items[*]). Sem ele, a lista é o próprio payload ou a primeira chave habitual com objetos (data, items, results, records, value, entries).
  • pagination: type = none, offset (offset_param e limit_param), page (page_param, page_start 0 ou 1), cursor (cursor_path no corpo e cursor_param no pedido) ou link (header Link com rel="next", ou next_path no corpo); page_size até 1000. Vale para todos os endpoints e cada endpoint pode ter a sua.
  • total_path: onde a API diz o total de registos (ex.: $.meta.total); sem ele, o número de linhas é o que a amostra leu.
  • allowed_hosts: hosts extra para onde os links de paginação podem apontar (ex.: um CDN). Recebem os pedidos sem a credencial.
  • sample_size (por omissão 1000, máximo 5000) e max_pages (por omissão 50, máximo 500): tetos da leitura de cada endpoint.

Como lê: só GET pelo ReadOnlyHttpClient (anti-SSRF com pinning de DNS). A leitura para quando uma página vem vazia ou incompleta, quando um cursor se repete, ao fim de max_pages páginas ou quando a amostra está cheia (rest_api.py: _collect). Os campos de topo de cada registo são as colunas; objetos e listas aninhados ficam como texto JSON nas linhas e com o tipo object ou array no catálogo.

Allowlist de hosts: cada pedido, incluindo os links de paginação e cada salto de um redirect, só sai para o host da base_url (na mesma origem) ou para um host de allowed_hosts; outro destino é recusado antes do pedido (rest_api.py: _check_host). A credencial só segue para a origem da base_url. Um host da allowlist que resolva para um IP interno continua bloqueado pelo guard anti-SSRF.

Contrato da API: com contract (um documento OpenAPI 3.x ou Swagger 2.0, em JSON ou YAML), cada perfil compara o schema da resposta 200 do GET do endpoint (o contract_path, ou o próprio path) com os registos da amostra, campo a campo, incluindo os aninhados (morada.cidade). O scan abre um issue schema_change com fonte contract e o impacto a jusante quando há drift: um campo required que não veio em nenhum registo («removida»), um campo que o contrato não declara («nova») ou um tipo fora do declarado (app/infra/connectors/api_contract.py; app/domain/scans/schema_changes.py: record_contract_drift). Um campo opcional ausente da amostra não conta, e uma amostra sem registos também não. O schema de um registo desce pelo caminho de onde a lista veio: o records_path, ou o que a deteção escolheu ($.data num envelope {"data": […]}) (rest_api.py: locate_records). Só se seguem os $ref do próprio documento: o contrato nunca manda o conector buscar nada. Um $ref em ciclo, como numa árvore, corta-se onde volta atrás, e cada schema lê-se uma só vez. A leitura tem tetos: 64 níveis de allOf/anyOf/oneOf encaixados e 1 000 000 passos, em que conta cada elemento percorrido (uma propriedade, um nome do required ou uma entrada de type copiados, um salto de $ref, uma chave de paths, content ou messages), mesmo quando vários schemas o partilham por $ref ou âncora YAML (api_contract.py: _SchemaReader). As chaves de fusão YAML (<<) copiam no máximo 1 000 000 pares (api_contract.py: _ContractLoader). Nos tópicos Kafka, o mesmo contract aceita um documento AsyncAPI 2.x ou 3.x e compara o payload da mensagem do canal com o nome do tópico (kafka.py: _contract_check).

Validação ao gravar (422): base_url em https e sem credenciais no URL, modo de autenticação e campos do modo, caminhos que começam por / sem .. nem query, JSONPath simples, tipo de paginação conhecido (o cursor exige cursor_path), api_key_header que não seja um header de protocolo, allowed_hosts só com nomes de host, tetos inteiros e um contract legível dentro dos tetos da leitura, com todos os schemas lidos já ao gravar (api_contract.py: validate_contract). Num tópico Kafka, um contract que não seja AsyncAPI, ou que passe esses tetos, também dá 422.

Instalação: nenhum extra (httpx).

Limites: sem OAuth2 nem cookies de sessão, e sem POST de pesquisa (uma API que só lista por POST fica fora). O header Link lido é o da RFC 8288.

Tickets (Jira, Linear, ServiceNow, Azure DevOps, GitHub, GitLab)

Não é uma fonte de dados: é a saída para a ferramenta de tickets da equipa. O BeryX cria o ticket a partir de um issue, incidente ou sugestão (botão «Criar ticket», tickets em lote e cartão do assistente) e guarda o link na entidade. A integração configura-se em Notificações → Tickets (admin). O «Testar ligação» valida o config como ao gravar e lê os destinos do fornecedor sem criar nada (POST /notification-channels/tickets/connection-test). Os seis fornecedores cumprem o mesmo contrato TicketProvider (app/infra/tickets/base.py: validate_config, create, get_status, list_targets) e estão no registo app/infra/tickets/registry.py.

Fornecedor kind API e corpo Autenticação Destinos (list_targets) Prioridade sugerida Estado (get_status)
Jira Cloud jira REST v3 (/rest/api/3/issue), corpo em ADF Basic (email + API token) projetos e tipos de issue (project/search?expand=issueTypes) no corpo do ticket statusCategory do issue
Linear linear GraphQL (issueCreate), corpo em markdown API key pessoal equipas (teams) campo priority (1 urgente … 4 baixa) state.type do issue
ServiceNow servicenow Table API (/api/now/table/{tabela}): incident, sn_customerservice_task ou outra Basic (conta de integração) ou OAuth client credentials grupos de atribuição ativos (sys_user_group) urgency e impact (o ServiceNow calcula a prioridade) state e active do registo
Azure DevOps Boards azure_devops REST wit/workitems/${tipo} com JSON Patch, descrição em HTML PAT (Work Items: Read & write) o projeto e os tipos de work item (wit/workitemtypes) no corpo do work item System.State do work item
GitHub Issues github REST POST /repos/{owner}/{repo}/issues, corpo em markdown token (PAT fine-grained com Issues: write) ou a GitHub App da integração git o repositório (GET /repos/{owner}/{repo}, confirma que tem issues) no corpo; labels datavault e data-quality state da issue (open/closed)
GitLab Issues gitlab REST v4 POST /projects/{id}/issues, descrição em markdown token (PRIVATE-TOKEN, scope api) ou o da integração git o projeto (GET /projects/{id}, confirma que tem issues) no corpo; labels datavault e data-quality state da issue (opened/closed)

Escrita: cada fornecedor escreve para criar o ticket. Na sincronização (cartão #381), o Jira e o Linear também comentam num ticket já ligado (comment, ligado por omissão) e, com transition na política da integração, fecham-no ou reabrem-no, só nos tickets do projeto (Jira) ou da equipa (Linear) da integração; nenhum edita outros campos. Há dois POST que não escrevem: o Linear lê por queries GraphQL e o ServiceNow em OAuth troca as credenciais por um token em POST /oauth_token.do. A sincronização de incidentes com o ServiceNow nos dois sentidos fica para o cartão #255.

Segurança: o config é cifrado com Fernet (TicketIntegration.config_encrypted) e a API nunca o devolve. O host que o cliente configura (site do Jira, instância do ServiceNow, base_url do Azure DevOps Server, api_url do GitHub Enterprise ou do GitLab self-managed) passa pela guarda anti-SSRF ao gravar e a cada pedido, com pinning do IP e sem redirects (app/infra/network/outbound.py). Os erros levam um excerto da resposta do fornecedor sem as credenciais, e os fornecedores não escrevem logs (app/infra/tickets/http.py).

Timeouts e repetições: cada pedido tem 20 s. As leituras (destinos, estado e o token OAuth) tentam até 3 vezes em 429 e 5xx, com esperas de 0,5 s e 1 s, ou o Retry-After até 5 s. A criação não se repete, porque depois de um 5xx o ticket pode já existir. Um 429 na criação sobe com o Retry-After e os tickets em lote voltam a tentar com o seu backoff (app/domain/integrations/ticket_bulk.py).

GitHub e GitLab com a integração git: em vez de um token próprio, o config pode levar git_integration_id. O token (ou a GitHub App) e o api_url vêm então da integração git do mesmo workspace e do mesmo fornecedor, e o repositório por omissão é o dela.

Interop

Contratos ODCS ↔ datacontract-cli (DCSPEC)

O contrato ODCS v3.1 que o BeryX exporta (GET /datasets/{id}/contract.yaml, domain/contracts/service.py) é verificado contra o datacontract-cli, a ferramenta de referência do ecossistema Bitol. A verificação é opcional e não corre no CI normal:

  • Suite local backend/tests/test_interop_datacontract.py (marcada slow): exporta o contrato de um dataset fixture com todos os tipos de regra, corre o CLI e reimporta o resultado com os nossos importadores (domain/rules/importers.py). Sem o binário datacontract os testes saltam.

    python -m venv /tmp/dc && /tmp/dc/bin/pip install -r backend/requirements-interop.txt
    cd backend && DATACONTRACT_BIN=/tmp/dc/bin/datacontract python -m pytest -m slow tests/test_interop_datacontract.py
    
  • Job de CI .github/workflows/interop.yml (só workflow_dispatch, com a versão do CLI como input): instala o CLI num venv isolado e corre a suite com DATACONTRACT_REQUIRED=1 (a ausência do binário falha em vez de saltar). Correr antes de subir ODCS_API_VERSION ou de mexer no export/import ODCS.

O datacontract-cli é dependência de DEV apenas (requirements-interop.txt, fixado): traz as suas próprias versões de pydantic/sqlglot, por isso fica num venv à parte e nunca entra na imagem de produção.

Resultado (datacontract-cli 1.2.1, ODCS v3.1.0) — critério: round-trip sem perda nos campos que suportamos.

Passo Resultado
datacontract lint Válido contra o JSON Schema ODCS v3.1.0.
import odcs → export odcs Sem perda: o documento reimportado é igual ao original (o CLI só reordena chaves e quebra linhas longas). Regras extraídas, skipped e compare_contract_to_schema idênticos.
Export → reimport no BeryX Recupera not_null, unique, pattern, row_count_min e freshness. range viaja como check type: sql e é declarado em skipped (limitação do standard: não há metric de intervalo).
tenant, domain, dataProduct, team (cartão #148) Válidos e sem perda no round-trip. O team usa o objeto da v3.1.0 ({members: [...]}); o array solto da v3.0 também passa no lint, mas está depreciado.
Bundle do data product (GET /domains/{id}/contract) O CLI lê um contrato por ficheiro (um YAML com vários documentos dá «found another document»). A suite parte o bundle e cada contrato passa no lint e no round-trip import/export odcs sem perda.

Incompatibilidades encontradas

  1. Bug nosso, corrigido: a SLA latency exporta element: <schema>.<tabela> (physicalName) e o importador ODCS lia o ponto como tabela.coluna — a regra de freshness voltava presa a uma coluna inexistente (encomendas). O importador passou a reconhecer name/physicalName antes de extrair a coluna (teste de regressão em test_rule_interop.py).
  2. export sodacl (CLI): invalidValues com arguments.pattern sai como invalid_count(col) = 0 sem o regex — o padrão perde-se. O check sql (range) sai como métrica SodaCL user-defined (o nosso importador SodaCL declara-o em skipped) e a SLA latency não é emitida. nullValues, duplicateValues, required e rowCount sobrevivem.
  3. export dbt-models (CLI): só emite not_null (de required/nullValues); duplicateValues não vira unique. Sem servers no contrato, os data_type saem no dialeto por omissão do CLI (NUMBER, TIMESTAMP_TZ) — para dbt, preferir o nosso contract.dbt.yml, que mapeia a partir de logicalType e emite unique.
  4. Tabela na conversão: o SodaCL do CLI usa o physicalName (vendas.encomendas) e o dbt/SQL usa o name (encomendas). O match de dataset no import aceita ambos, mas convém saber qual aparece em cada artefacto.
  5. Tipos lógicos: exportamos TIMESTAMP como logicalType: date (o physicalType preserva o tipo real). O ODCS v3.1 tem timestamp; não foi mudado aqui porque compare_contract_to_schema e o mapeamento dbt dependem do valor atual — candidato a cartão próprio.