Visão de CEO — design
docs/superpowers/specs/2026-09-22-visao-de-ceo-design.md
Visão de CEO — design
Data: 2026-09-22 · Origem: brainstorm de 21/09/2026 (artefato visual: claude.ai/code/artifact/f9aa867d) · Status: aprovado pelo fundador em 22/09, aguardando revisão do texto.
1. O que é
Uma área nova em tynna.app/empresa onde cada área da empresa aparece com um estado (verde, amarelo, vermelho, desconhecido), o fundador desce só onde algo está fora do padrão e decide o que é dele (aprovar, vetar, escalar) sem sair da tela. Substitui o dashboard atual (/cockpit, /, /m/triage) que lê as fontes ao vivo em cada render e por isso ninguém abre.
Critério de sucesso, nas palavras do fundador: "abro todo dia e sei em um minuto onde olhar, sem perguntar ao agente".
Decisões tomadas no brainstorm
| Pergunta | Decisão |
|---|---|
| Propósito | Saúde por área e mergulhar no que está fora do padrão. Não é feed de atividade. |
| Áreas na v1 | Só o que tem fluxo real hoje (Desenvolvimento). A estrutura cresce por contrato conforme áreas nascem. |
| O que o humano faz | Ver e decidir: aprovar, vetar, escalar. Nunca editar código, prompt, gate ou contrato pela tela. |
| Onde | tynna.app, substituindo o dashboard atual. |
| Abordagem | Estado materializado por contrato: um coletor agendado grava retratos numa tabela; a tela só lê a tabela. |
Por que "estado materializado" e não "ler ao vivo" nem "eventos"
- Ler ao vivo (o que
/cockpitfaz hoje) acopla a tela a quatro APIs, fica lento, e cada abertura da página custa chamadas. Sem histórico, não há tendência. - Eventos (webhooks GitHub/Paperclip → fila → projeção) é o desenho certo para escala, mas exige receptor sempre no ar, validação de assinatura e reprocessamento. Para uma área instrumentada, é peso sem retorno.
- Retrato a cada 15 min dá histórico de graça, um único ponto de falha vigiado pelo dead-man switch, e pode virar eventos depois sem mudar a tela (a tela só conhece a tabela).
2. Arquitetura
Fontes ──▶ coletor-de-estado ──▶ retratos_de_area ──▶ tynna.app/empresa
(GitHub, (workflow agendado, (Supabase) │
Paperclip, */15, declarado no ▼
agent_runs, dead-man switch) decisoes_tomadas ──▶ GitHub / Paperclip
vigia/host) ▲ (trilha) (label, status, comentário)
│
config/sinais.yml (contrato por área)
Quatro peças, uma seta só. A tela nunca fala com as fontes.
| Peça | Responsabilidade | Depende de |
|---|---|---|
config/sinais.yml | Declarar, por área: fontes, sinais, limiares e decisões do fundador. | nada |
scripts/estado/ (coletor) | Ler o contrato, consultar fontes, avaliar limiares, gravar um retrato por área. | contrato, credenciais de leitura, Supabase |
retratos_de_area, decisoes_tomadas | Guardar retratos e trilha de decisões. | migration em apps/web/supabase/migrations/ |
apps/web/app/(dashboard)/empresa/ | Ler a última linha por área, mostrar, e aplicar decisões na fonte gravando na trilha. | tabelas, sessão do usuário |
O coletor é um gatilho agendado no mesmo padrão que substituiu a frota (ADR-0083): sem sessão viva, sem credencial própria além das da esteira, declarado em config/mecanismos-agendados.yaml para que o dead-man switch alarme se parar. É o "twin de plantão" da extensão 15 §7.1, materializado numa tabela.
3. O contrato de sinais — config/sinais.yml
Instrumentar uma área é declarar isto. Não é construir tela. Mesma filosofia do config/escopos.yml.
# config/sinais.yml — cada área declara o que é saúde e o que é decisão do fundador
versao: 1
coleta:
cadencia_minutos: 15
retrato_velho_apos_minutos: 30 # a tela destaca "retrato de há N min" a partir daqui
areas:
desenvolvimento:
nome: "Desenvolvimento"
sinais:
fila_de_merge:
fonte: github.prs_abertos # lista de PRs abertos com idade
amarelo: "algum.idade_horas > 24"
vermelho: "algum.idade_horas > 72 or total > 8"
gates_no_main:
fonte: github.checks_main # conclusão dos required no head de main
vermelho: "algum.conclusao != 'success'"
custo_agentic_semana:
fonte: supabase.agent_runs # soma de custo dos últimos 7 dias
amarelo: "valor > teto * 0.8"
vermelho: "valor > teto"
parametros: { teto_usd: 60 }
host_ci:
fonte: github.runners # runners online/ocupados no host
amarelo: "ocupados >= total"
migrations_orfas:
fonte: config.migrations_sem_pipeline
amarelo: "total > 3"
decisoes:
- id: ratificar_adr
fonte: github.prs_com_label
parametros: { label: "adr" }
acoes: [aprovar, vetar]
- id: aprovacao_l3
fonte: github.prs_tier_high_vetados
acoes: [aprovar, vetar, escalar]
- id: decisao_de_board
fonte: paperclip.cards_com_tag
parametros: { tag: "decisao-board" }
acoes: [aprovar, vetar, escalar]
- id: decisao_do_motor
fonte: supabase.tynna_decisions_pendentes
acoes: [aprovar, vetar]
sustentacao:
nome: "Sustentação"
sinais: {} # declarada sem sinal: aparece cinza como lembrete
decisoes: []
Regras do contrato (validadas ao carregar; violação = coletor recusa rodar)
- Toda fonte citada existe no registro de fontes do coletor (
scripts/estado/fontes/). Fonte desconhecida é erro, nãodesconhecido. - Toda expressão de limiar é parseável pela gramática abaixo. Erro de sintaxe é erro de carga, não sinal verde por acidente.
- Toda decisão declara
acoescomo subconjunto de[aprovar, vetar, escalar]. Não existe açãoeditar; o esquema a recusa. - Área declarada com
sinais: {}é permitida (aparece cinza). Área ausente do contrato não aparece na tela. - Ordem de avaliação:
vermelho, depoisamarelo; nenhuma casando =verde. Sinal semvermelhonemamareloé erro de carga (sinal que nunca muda não é sinal).
Gramática de limiar
Expressão booleana sobre o valor da fonte. Subconjunto pequeno, avaliador próprio (sem eval):
- Operandos:
valor,total, campos numéricos do valor (ocupados),parametros.*(abreviado:teto), literais numéricos e strings entre aspas simples. - Quantificadores sobre listas:
algum.<campo> <op> <literal>,todos.<campo> <op> <literal>. - Operadores:
> >= < <= == !=,and,or,not, parênteses. - Resultado:
true,false, oudesconhecidose qualquer operando não existe no valor (nunca coage ausência para 0).
4. Modelo de estado
retratos_de_area — uma linha por área por coleta
| coluna | tipo | nota |
|---|---|---|
id | uuid pk | |
area | text | chave do contrato |
coletado_em | timestamptz | |
estado | text | verde | amarelo | vermelho | desconhecido — o pior entre os sinais (vermelho > desconhecido > amarelo > verde) |
estado_desde | timestamptz | calculado: coletado_em do retrato mais antigo da sequência contígua com o mesmo estado |
sinais | jsonb | objeto com uma chave por sinal do contrato; cada valor é { valor: unknown, estado: Estado, limiar_casado: string | null, fonte: string, url: string | null } |
decisoes | jsonb | lista de pendentes nesta coleta; cada item é { id: string, ref: string, titulo: string, contexto: string, url: string, acoes: Acao[] } |
fontes_falhas | text[] | fontes que não responderam nesta coleta |
Índice (area, coletado_em desc). Retenção: 90 dias, apagados pelo próprio coletor ao fim de cada corrida (não é job separado, para não virar mecanismo agendado a mais).
Fora do padrão tem definição precisa, calculada na leitura: estado != 'verde' ou estado difere do retrato anterior da mesma área ou decisoes não vazio. É isso que sobe para o topo da tela.
decisoes_tomadas — trilha, escrita antes de tocar a fonte
| coluna | tipo | nota |
|---|---|---|
id | uuid pk | |
area, decisao_id | text | chave do contrato |
ref | text | PR #53, TYN-3533, uuid de tynna_decisions |
acao | text | aprovar | vetar | escalar |
motivo | text null | obrigatório para vetar e escalar |
por | uuid | auth.uid() de quem clicou |
em | timestamptz | |
efeito | jsonb | { tentado: { fonte: string, operacao: string, alvo: string }, ok: boolean | null, resposta: unknown, erro: string | null } — ok=null enquanto a fonte não respondeu |
Relação com tynna_decisions (já existe)
tynna_decisions é a fila de decisões pendentes do motor F1 (TTL 3 dias úteis, ADR-0075). Não é substituída: vira uma fonte (supabase.tynna_decisions_pendentes) e aparece em "Espera você". Aprovar/vetar por ali grava em decisoes_tomadas e atualiza o status na própria tynna_decisions. /m/triage deixa de existir quando /empresa cobrir as três origens que ele lia (Paperclip, PR, tynna_decisions).
RLS
Ambas as tabelas: RLS ativo. Leitura para authenticated com papel board (mesmo predicado usado por tynna_decisions). Escrita em retratos_de_area só por service_role (coletor). Escrita em decisoes_tomadas por authenticated board via server action, com por = auth.uid() forçado por policy with check.
5. O coletor — scripts/estado/
scripts/estado/
coletar.ts # entrada: carrega contrato, corre fontes, avalia, grava, poda
contrato.ts # parse + validação do sinais.yml (regras §3)
avaliar-limiar.ts # parser + avaliador da gramática (§3), puro
retrato.ts # monta a linha: pior estado, estado_desde, fontes_falhas
fontes/
index.ts # registro: nome → função (parametros) → Promise<Valor | Falha>
github.ts # prs_abertos, checks_main, runners, prs_com_label, prs_tier_high_vetados
paperclip.ts # cards_com_tag
supabase.ts # agent_runs, tynna_decisions_pendentes
config.ts # migrations_sem_pipeline (lê o YAML do repo)
Fluxo de uma corrida:
- Carrega e valida o contrato. Inválido → exit 1 com a linha do erro; nada é gravado.
- Para cada área, para cada fonte distinta citada: chama uma vez (fontes são memoizadas por
(nome, parametros)dentro da corrida). Falha → registra emfontes_falhas; todos os sinais dessa fonte ficamdesconhecido. - Avalia limiares. Expressão que resulta
desconhecido→ sinaldesconhecido, log com a expressão. - Monta o retrato, consulta o anterior para calcular
estado_desde, insere. - Apaga retratos com
coletado_em < now() - 90d. - Exit 0 mesmo com fontes falhas (o retrato foi gravado; o cinza na tela é o alarme). Exit 1 só para contrato inválido ou falha ao gravar.
Workflow .github/workflows/coletor-de-estado.yml: schedule: */15 * * * * + workflow_dispatch, concurrency: { group: coletor-de-estado, cancel-in-progress: false }, permissions: { contents: read, pull-requests: read, checks: read, actions: read }, runner tynna-novo. Entrada em config/mecanismos-agendados.yaml:
- id: coletor-de-estado
cadencia: "*/15"
fonte: "workflow:coletor-de-estado.yml:*/15 * * * *"
dono: twin
efeito_se_nao_rodar: "tynna.app/empresa mostra retrato velho; fundador decide sobre dado morto"
Credenciais (todas de leitura, Keychain → GitHub Secrets, nunca no repo):
| secret | uso | existe hoje? |
|---|---|---|
GITHUB_TOKEN (do workflow) | PRs, checks, runners | sim |
PAPERCLIP_API_KEY_LEITURA | cards | não — cunhar chave só-leitura no Paperclip |
SUPABASE_SERVICE_ROLE_KEY_WEB | gravar retratos, ler agent_runs/tynna_decisions | não — copiar do projeto web |
6. A tela — apps/web/app/(dashboard)/empresa/
Três níveis. Server components; a página lê apenas retratos_de_area (última linha por área + as 7 anteriores para a faixa) e decisoes_tomadas (para mostrar o que já foi decidido e não repetir).
Nível 1 — áreas. Uma linha por área do contrato: pastilha de estado, nome, "há N dias" (de estado_desde), "N esperam você". Ordenadas por fora-do-padrão primeiro. Cabeçalho: "retrato de há N min · próximo em M"; se N > retrato_velho_apos_minutos, o cabeçalho vira alerta.
Nível 2 — sinais da área selecionada. Um por linha: pastilha, nome, valor com unidade, faixa dos últimos 7 retratos. Clicar leva à origem (url do valor: lista de PRs, run, card). desconhecido é cinza com o nome da fonte que falhou; nunca verde.
Nível 3 — "Espera você". Um item por decisão pendente: título, contexto (extraído do PR/card pelo coletor: primeiras 400 chars do corpo ou do resumo do ADR), botões conforme acoes do contrato. vetar e escalar abrem campo de motivo obrigatório. Botão com loading + disabled no submit; server action idempotente (mesma (decisao_id, ref, acao) já na trilha com ok=true → não repete).
Rotas antigas: /cockpit e /m/triage passam a redirecionar para /empresa no mesmo PR que entrega o nível 3; lib/cockpit/* e lib/triage/* são removidos quando nada mais os importa (o execute-decision.ts do triage é reaproveitado para as ações Paperclip, com o repo corrigido para marckrs/tynna).
7. Como a decisão chega na fonte
| decisão | ação aprovar | ação vetar | ação escalar |
|---|---|---|---|
ratificar_adr | label ratificado no PR + comentário "ratificado por <login> em <data>" | comentário com motivo; não fecha | — |
aprovacao_l3 | label ciso-l2-aprovado no PR | comentário com motivo | card no Paperclip com tag decisao-board, link do PR |
decisao_de_board | comentário com a opção + status in_progress | comentário + status cancelled | status blocked + comentário |
decisao_do_motor | tynna_decisions.status = 'approved' | = 'rejected' + motivo | — |
Identidade com que a ação é aplicada — requisito duro
O gate ciso-l2 lê o timeline do PR e só aceita label vinda de um User. Se o painel aplicasse como App ou com token de bot, o gate rejeitaria. Logo, toda ação em GitHub usa o token GitHub de quem está logado.
Hoje o login do tynna.app é e-mail+senha (Supabase). Isto adiciona:
- Provedor GitHub no Supabase Auth do projeto web, escopo
repo(o repo é privado), e a opção "Conectar GitHub" na página de login e no próprio/empresa. - O
provider_tokenda sessão é usado só no servidor (server action) e nunca chega ao cliente. Ele expira em ~8h sem refresh; token ausente ou expirado → o botão de ação GitHub vira "Reconecte o GitHub", sem tentar fallback para credencial da esteira. - Ações no Paperclip e em
tynna_decisionsnão têm essa restrição e usam a credencial do servidor (PAPERCLIP_BOARD_API_KEY,service_role), comporgravado na trilha.
Um teste estático garante que o cliente GitHub do painel recebe token só de session.provider_token e nunca de process.env.
Ordem de escrita
Toda ação: (1) insere em decisoes_tomadas com efeito.tentado e ok=null; (2) chama a fonte; (3) atualiza efeito com ok e resposta/erro. Se (2) falhar, a linha fica com ok=false e a tela mostra "não aplicado: <erro>". Auditoria não depende de a chamada ter funcionado.
8. Quando algo dá errado
| falha | comportamento |
|---|---|
| Uma fonte não responde | sinais dela desconhecido, fonte em fontes_falhas, retrato gravado. Tela: cinza com o nome da fonte. Nunca verde. |
| Coletor para de rodar | dead-man switch alarma (mecanismo declarado). Tela: cabeçalho em alerta após 30 min. |
| Contrato inválido | coletor recusa rodar, exit 1 com linha; último retrato bom permanece; dead-man não dispara (o workflow rodou), mas o job fica vermelho e a tela envelhece → alerta em 30 min. |
| Limiar não avaliável para o valor | sinal desconhecido, log da expressão. |
| Botão falha na fonte | trilha com ok=false e erro; tela "não aplicado: <motivo>". Não finge sucesso. |
| Token GitHub do usuário expirado | botão vira "Reconecte o GitHub"; nenhum fallback. |
| Duas coletas simultâneas | concurrency no workflow; a segunda espera. |
| Retrato duplicado na mesma coleta | unique (area, coletado_em); insert idempotente. |
9. O que fica travado por teste
| invariante | onde |
|---|---|
Contrato valida por esquema: fonte desconhecida, limiar mal formado, sinal sem limiar, ação fora de [aprovar, vetar, escalar] reprovam | tests/standards/sinais-contrato.test.ts |
Avaliador de limiar é puro, cobre cada operador, quantificadores, e desconhecido por operando ausente (nunca coage para 0) | scripts/estado/avaliar-limiar.test.ts |
Fonte falha ⇒ nenhum sinal dela é verde — prova de mutação: o teste injeta uma fonte que lança e afirma que trocar o desconhecido por verde no código reprova | scripts/estado/coletar.test.ts |
Pior estado: vermelho > desconhecido > amarelo > verde | scripts/estado/retrato.test.ts |
estado_desde reinicia na troca de estado e persiste na sequência contígua | scripts/estado/retrato.test.ts |
Coletor está em mecanismos-agendados.yaml com fonte: que casa o schedule do workflow | já coberto pelo gate do dead-man |
Nenhum componente de app/(dashboard)/empresa importa cliente que escreva em código, prompt, gate ou contrato (lista negra: octokit.repos.createOrUpdateFileContents, git, fs.write*) | tests/standards/painel-so-decide.test.ts |
| Toda ação grava na trilha antes de chamar a fonte (ordem observada por mock) | apps/web/lib/empresa/__tests__/decidir.test.ts |
Cliente GitHub do painel recebe token só de session.provider_token; process.env.GITHUB_TOKEN não aparece em lib/empresa/ | tests/standards/painel-token-do-usuario.test.ts |
Server action idempotente: repetir (decisao_id, ref, acao) com ok=true não chama a fonte de novo | apps/web/lib/empresa/__tests__/decidir.test.ts |
RLS: authenticated sem papel board não lê; authenticated não escreve em retratos_de_area; por ≠ auth.uid() é recusado | suite pgTAP em apps/web/supabase/tests/ |
E2E happy-path: abre /empresa, vê área amarela, abre sinal, veta uma decisão com motivo, vê "vetado" | Playwright, mesmo PR do nível 3 |
10. Pré-requisitos que não são deste desenho
Descobertos ao conferir o repo; sem eles a v1 não liga, e por isso entram como Fatia 0 do plano, não como surpresa no meio.
db-pushparaapps/web. Hoje.github/workflows/db-push.ymlenumeraapps/*/supabase/migrationsmas para no "estado 3 — ainda NÃO está implementado (Passo 34)". Criarretratos_de_areasem isso seria a 4ª e 5ª migration órfã (config/migrations-sem-pipeline.yml). O plano implementa osupabase db pushpara o projeto web antes de qualquer migration nova.- Dois secrets de leitura (
PAPERCLIP_API_KEY_LEITURA,SUPABASE_SERVICE_ROLE_KEY_WEB) e provedor GitHub no Supabase Auth: ações do fundador, Keychain → GitHub Secrets/painel Supabase, quando estiver no Mac. - Label
ratificadoe tagdecisao-boardcriados no GitHub e no Paperclip (o coletor não cria; recusa com erro claro se não existirem).
11. Fora de escopo, de propósito
- Não é tempo real. Retrato a cada 15 min. O padrão que sobrevive nos projetos agentic é estado e exceção, não feed.
- Não edita nada. Prompt, gate, contrato e código continuam sendo pull request.
- Não substitui o Paperclip. O quadro segue sendo a fonte da verdade do trabalho; o painel lê dele e escreve nele.
- Não inventa área. Sustentação, atendimento, vendas e mídias entram quando tiverem gatilho, juiz e sinal.
- Não define saúde por domínio. O que faz papel de teste em atendimento ou vendas é pergunta aberta; este painel mostra o estado quando existir.
- Não notifica. Telegram/push continuam sendo os alarmes existentes; a tela é onde se olha, não o que chama.
12. Fatias sugeridas para o plano
Cada fatia entrega algo verificável sozinho e passa pela esteira como PR normal.
db-pushreal paraapps/web(pré-requisito 1).- Contrato + avaliador + validação (
config/sinais.yml,scripts/estado/contrato.ts,avaliar-limiar.ts, testes). Sem fonte, sem tabela: só a gramática travada. - Migration das duas tabelas + RLS + pgTAP.
- Coletor com fontes
github.*econfig.*(as que só precisam doGITHUB_TOKEN), workflow, entrada no dead-man. Já produz retratos reais. - Tela níveis 1 e 2 (leitura), redirect de
/cockpit. - Fontes
paperclip.*esupabase.*(dependem do pré-requisito 2). - Nível 3: trilha, ações Paperclip e
tynna_decisions(credencial de servidor), redirect de/m/triage, E2E. - Provedor GitHub no Supabase Auth + ações GitHub com token do usuário (pré-requisito 2).
Só leitura. Toda mudança nestes documentos é pull request.