# Limites do Horus — o escopo modela para o motor real, não para o livro

## Conteúdo

- 1. Relacionamentos e fanout — o limite mais caro
- 2. Como o escopo se materializa (o bundle promete o que o Horus entrega)
- 3. Decisões de escopo que só existem por causa da ferramenta

O gerador de queries do Horus é **automático**: você declara relacionamentos e
ele decide os joins e a propagação de filtros. Isso torna a plataforma rápida de
usar e cria uma classe de limite que Kimball-de-livro não conhece: **modelo
tecnicamente correto que o motor executa errado ou caro**. Esta referência é a
verdade tool-specific que o escopo precisa respeitar — evidência: documentação
oficial (docs-horus, citada como `arquivo:linha`), estatística de produção
(560 tenants vivos) e casos reais anonimizados.

## 1. Relacionamentos e fanout — o limite mais caro

**Mecânica** (doc oficial): todo relacionamento é 1:N direcional, lado "1" com
unicidade validada ao salvar; N:N exige tabela de ligação; filtro em dimensão
propaga automaticamente para toda fato conectada, inclusive em cadeia
(`dataviz/02-apps/tables.md:40-65,204-218`). Fato↔fato direto é bloqueado
(explosão cartesiana, erro `Cartesian product detected` —
`data-modeling.md:20-24`, `llm/error-codes.md:34`).

**O fanout silencioso — "Dimensões Exclusivas"** (`data-modeling.md:186-231`):
ao cruzar métricas de DUAS fatos num relatório usando uma dimensão que só existe
numa delas, o motor gera uma query por fato e une os resultados — o mesmo
registro aparece em várias linhas, com a dimensão vazia numa delas. **Sem erro,
sem bloqueio, número que parece duplicado/inflado.** Regra oficial: *"ao cruzar
métricas de duas Tabelas Fato, utilize apenas Dimensões que existam em ambas"*.
Pior: a propagação de filtro entre dimensões via fato compartilhada é
**condicional às colunas que o widget usa** (`tables.md:220-249`) — o mesmo
filtro corta num widget e não corta no vizinho. E a doc é explícita: *"quanto
mais conexões o modelo tiver, mais os filtros se propagam entre si"*; *"evite
ciclos"* (`tables.md:255,264`).

> **Não confunda com `dedupe`.** O dedupe do profiling trata duplicata DENTRO de
> uma fonte (grain check). Fanout é duplicação ENTRE fatos no momento da consulta
> cruzada — acontece com cada tabela individualmente perfeita.

**Caso real (anonimizado, números de produção):** um projeto chegou a apps com
**58-61 relacionamentos sobre 25-27 tabelas (2,3 por tabela — tudo ligado em
tudo, "constellation")**. Sintoma: filtros com fanout, números inesperados sem
erro nenhum. Foram 11 versões do mesmo app tentando consertar na camada de
modelo. A correção definitiva foi **consolidar no tratamento**: um fato mestre
único absorvendo no ETL o que os relacionamentos tentavam resolver em tempo
real. A geração reconstruída roda com **1,0 relacionamento por tabela** e telas
satélite com 5-6 tabelas.

**O padrão documentado na plataforma — Tabela Fato Única** (`data-modeling.md:295-350`):
unificar fatos via UNION ALL no ETL (nó Concatenar ou SQL DuckDB), alinhando
colunas comuns e preenchendo específicas com 0/null. Quando usar (doc oficial):
granularidades diferentes, estruturas semelhantes, ou "simplificar o modelo,
eliminando Relacionamentos complexos". Trade-off documentado: menos flexível
para novas fontes + colunas esparsas — em troca de um modelo que o motor executa
de forma previsível.

**As regras opinativas da skill:**
- **Vários fatos é legítimo — desde que TODAS as dimensões em comum sejam
  compartilhadas.** É o padrão oficial ("Dimensões Compartilhadas", regra de
  ouro em `data-modeling.md:98-99`: *"todas as Dimensões em comum entre Tabelas
  Fato devem ser conectadas através de Tabelas Dimensão"*) — exatamente o que o
  bus matrix desenha: N fatos × as MESMAS dimensões conformadas (Calendário,
  Filial, Produto…), cada fato ligando nelas de forma independente.
- **A regra dos ciclos (a simplificação que resolve o fanout):** duas dimensões
  que se ligam a fatos **nunca se ligam entre si**. Snowflake é seguro
  justamente por isso: fato → dimensão → dimensão-externa, onde a externa NÃO
  liga em fato nenhum — não há ciclo fechado, não há caminho duplo de
  propagação de filtro. O constellation nasce quando dimensões viram pontes
  (dimensão ligando em vários fatos E em outras dimensões ao mesmo tempo).
- **Thumb rule de normalização — Star sempre, snowflake só com motivo:** por
  default, o atributo da dimensão-pai vira **coluna de texto dentro da
  dimensão-filha** — nome e sigla do estado como colunas da dimensão Cidade
  (nem existe dimensão Estado); GRUPO e SUBGRUPO como strings na dimensão
  Produto (é um flow a menos para construir e manter). Normalize em duas
  tabelas (snowflake) **apenas quando a dimensão externa é necessária em algum
  fato**: uma medida no grão dela (população por estado), uma meta definida
  naquele grão (meta de vendas por grupo), ou outro fato do BI que liga direto
  nela. Sem esse motivo, o modelo "desnormalizado" de N fatos + N dimensões
  star é o correto.
- **Verificação rápida (checkpoint)**: razão relacionamentos/tabelas ≈ 1,0 é
  saudável; > 1,5, dimensão ligada a outra dimensão que também liga em fato, ou
  dimensão-ponte entre fatos = constellation — recondução obrigatória.
- **Um processo com N estados = 1 fato com coluna de estado, não N fatos.**
  Pedido, faturamento e cancelamento (ou orçamento→aprovado→executado) são a
  MESMA entidade em momentos diferentes (`ORIGEM`/`SITUACAO` degeneradas via
  UNION no extract). Métricas que comparam estados ("% cancelamento",
  "conversão orçamento→pedido") só computam corretamente com os estados
  coexistindo na mesma tabela — nunca via relationships entre fatos-de-estado.
- **Grain fino + flags de dedup materializadas > grains múltiplos.** Escolha o
  grain mais fino que o negócio pergunta (diária) e materialize flags no ETL
  (`PRIMEIRO_DIA`, `PRIMEIRO_X_DIA` via ROW_NUMBER) para responder o grain
  grosso sem segunda tabela: `SUM(valor * FLAG)`. O anti-fanout fica codificado
  UMA vez (com a explicação no human_description da flag), não repetido por app.
- **Razão entre processos = coluna no fato, não join.** KPI cujo denominador
  vem de OUTRO processo (utilização = usado/disponível; atingimento =
  realizado/meta; produtividade = produzido/capacidade): o denominador vira
  coluna do próprio fato via CTE no tratamento — não um segundo fato
  relacionado. Se o KPI é razão entre dois processos, o denominador entra no
  fato mestre.
- **Fato mestre é a escalada, não o default.** O default é vários fatos com
  dimensões compartilhadas. Escale para consolidação no tratamento quando os
  sinais aparecem: KPI que é razão entre processos (denominador vem de outro
  fato — utilização, atingimento de meta diária, produtividade); funil/ciclo de
  vida da MESMA entidade (pedido→faturamento→entrega→devolução); negócio de
  ocupação de capacidade (diárias, agendas, assentos, leitos, salas); ou a tela
  exige cruzar fatos por dimensões que NÃO são comuns aos dois. Aí a skill RECOMENDA fato mestre e
  registra (`estrategia: fato-mestre`; os processos continuam linhas
  conceituais do bus matrix).
- **O dicionário de métricas mora no fato**: os KPIs canônicos do domínio
  (no caso real, 22 fórmulas) são expressions DA TABELA — todos os apps herdam;
  o bundle entrega o dicionário de métricas junto do fato, uma única vez.
- **Widget que cruza 2 fatos** exige nota na ficha: dimensões estritamente
  comuns + "drill-across, nunca join fato-fato" — checado na revisão / no checkpoint.
- **Múltiplas datas na mesma fato** (emissão/entrega) NÃO viram segunda
  calendar table: uma Calendário única + chave secundária + `use()` na
  expressão ("Relacionamento Fantasma", `data-modeling.md:158-182`). Limite:
  `use()` só aceita coluna física; chave composta não suportada — concatene no
  ETL.

## 2. Como o escopo se materializa (o bundle promete o que o Horus entrega)

- **`papel: fato|dimensao|bridge` é rótulo do bundle.** O campo real
  (`apps[].tipo`) só aceita `dimension`/`n2n`, rejeita `fact`, e em produção é
  usado de forma inconsistente. Só `bridge`→`n2n` mapeia 1:1. Não prometa ao
  cliente que a plataforma "mostra" fato vs dimensão.
- **Relationships vivem POR APP e se redeclaram.** Zero relationships em
  tabela; o mesmo par fato↔dimensão foi visto redeclarado em 12 apps de um
  tenant. O `lineage.md` do bundle é a fonte autoritativa dos joins — cada app
  novo copia dali (join no app que não está no lineage = scope creep). Isso é
  custo real de sizing: dashboard novo que reusa par existente ainda religa.
- **SCD2 não é nativo.** Zero casos em produção (nenhum valid_from/is_current em
  centenas de dimensões); é build manual (surrogate + fechamento de versão) e
  incompatível com load Total. O default SCD2 da skill continua (o custo de
  errar é irreversível) — mas o escopo precifica o build e valida a viabilidade
  antes de prometer.
- **`chave_negocio` ≠ `key_columns`.** `key_columns` é prefixo de ordenação
  física do Doris (em produção, 1 coluna solta tipo ESTACOD); o grain real se
  reforça por `key_type: unique` (raro), dedupe em expression, ou — o caso
  dominante — pelo **delete-window do load Temporal**. O bundle declara COMO o
  grain é reforçado na prática, ou registra risco.
- **Expressions têm 2 camadas**: tabela (reusável por qualquer app; SQL cru) e
  app (local; único lugar onde `{FILTER|COLUMN=...}` vale). Métrica usada em 2+
  dashboards mora na tabela — senão vira N cópias divergentes (visto em
  produção: 5 cópias do mesmo app com contagens de expressions diferentes).
- **`facts:` do app é vestigial** (100% vazio em vários tenants) — o dicionário
  do chatbot é entrega opcional, não assuma que existe.
- **Semi-aditiva sem `LAST()`**: o motor não tem função de janela — saldo/fim de
  período se expressa como `SUM(CASE WHEN <corte temporal> THEN valor ELSE 0
  END)`. A decisão de ESCOPO é a condição de corte (fim de mês? snapshot mais
  recente por chave?) — vai explícita no KPI card.
- **`mask` é obrigatório** em toda coluna/expression numérica, e é decisão de
  escopo, não de scaffold. Os valores em uso: `MOEDA`, `N0`, `N1`, `N2`, `N3`,
  `PCT0`, `PCT1`, `PCT2`, `DATA`.

  **Não trate essa lista como enum fechado.** O schema de lint do CLI declara
  `mask` como string livre (`{"type": ["string","null"]}`), e a doc do produto
  apresenta esses valores como exemplos. O que o backend aceita além deles não
  está verificado.

  Isso importa em **unit economics** (R$/km, R$/ton, R$/hora), comum em
  transporte, agro e indústria: nenhum valor da lista expressa "moeda por
  unidade". Use `MOEDA` (é o que mais se aproxima, e o número continua legível),
  registre a limitação em `incognitas.md` com `dono: analista` e
  `gatilho: antes de publicar a tabela`, e escreva o denominador no `label`
  ("Custo por Km (R$/km)"). Não invente um valor de mask novo sem confirmar com
  o time de produto: ele pode passar no lint e falhar no render.
- **Grid é configurável por tela**: 12 (66% da produção), 24, 36 ou 60 colunas.
  O wireframe usa 12 como convenção de mockup; a ficha declara `grid_columns`
  se o dashboard pedir densidade diferente.
- **Load types em produção**: Total 59% / Temporal 27% / Incremental 14% (vários
  tenants com zero Incremental). Incremental exige coluna de tracking
  data/monotônica PROVADA — caso real de `load_type_column: NM_PACIENTE` (nome
  de pessoa!) em produção mostra o que acontece sem a trava do profiling.
- **Coluna de partição mutável = duplicata** (erro documentado
  `llm/error-codes.md:54`): nunca particionar por data_vencimento/updated
  inconsistente.
- **Tabela headless/seed manual existe** (originFlowId null, `table_type: file`,
  upload avulso) — origem de primeira classe no lineage ("seed manual, sem ETL
  repetível"); não prometa `lumo flow logs` como verificação de frescor dela.
- **Agendamentos do MESMO tenant serializam** (paralelismo só entre tenants —
  `etl/guides/requisitos-hardware.md:33-42`): projeto com muitos flows no mesmo
  agente enfileira; janela de carga total é decisão de escopo quando o cliente
  exige tudo pronto às 7h.
- **Limites numéricos úteis ao escopo**: Bar/Line Top-N máx 500; **Pie default
  Top 10 fatias** (o produto empurra a violação por padrão); Join do ETL só
  INNER/LEFT com até 4 chaves; Cadastros travam em 100k linhas/CNPJ (dimensão
  grande vai de Dataflow, não Cadastro); catálogo de Fatos IA degrada acima de
  ~25-50 conceitos por app (`lumia/fatos.md:145-193` — precedente documentado para
  "menos entidades, melhor sistema").

## 3. Decisões de escopo que só existem por causa da ferramenta

- **Fonte em migração** (2 sistemas coexistindo para o mesmo processo): decidir
  em projeto.md — substitui e descontinua / paralelo por período / consolida já.
  Sem essa trava nascem dois star schemas paralelos nunca conformados (caso real
  com gerações "-gp"/"-webposto").
- **Consolidação de grupo/rede**: fonte via agente de OUTRO tenant existe em
  produção — muda quem aprova acesso e o RACI (cruza tenant); declarar no
  lineage.
- **Planilha manual recorrente** ("subo o Excel da campanha toda quinzena"):
  perguntar se a tabela é reusada (upload substitui) ou nasce uma por período —
  tabela-por-período é dívida técnica assinada; registrar custo.
- **Tabelas de infraestrutura** (config/plumbing de ETL) ficam fora do bus
  matrix e do lineage de negócio — 1 linha em projeto.md para o total de
  tabelas não estranhar depois.
- **Extração que agrega na origem** (SQL com GROUP BY de 30 colunas): o grain
  real da fato é a lista do GROUP BY — documentar todas; mudar métrica = 
  reescrever a query. Decisão estrutural de alto risco, não detalhe (checagem
  no profiling).
