Back to the catalog

Constituição financeira

Bundle OKF 0.2 · 0 conceitos · regisleandro/clara-financas

Open source Repository Open in the app JSON README (API)

About

# Constituição financeira

O contrato do domínio: a taxonomia de categorias, as convenções de extração e
as regras que geram alertas. Escrito por uma pessoa, alterado só por edição
direta neste repositório — a Clara lê, nunca escreve aqui.

O que a Clara aprende sobre você vive no outro bundle, `learnings`, e passa
por aprovação explícita.

- `categories/` — a taxonomia de gastos
- `conventions/` — schema da transação e política de conferência
- `rules/` — condições que viram sugestão de economia

Details

Kind
OKF bundles
Topic
No topic detected
Publisher
regisleandro
Origin
okf_github
Category
dados
Version
0.2
Last push
2026-08-24T03:23:55Z
Repository state
ativo
Language
TypeScript
Added
2026-09-08 09:04:14
Updated
2026-09-08 09:04:14
Origin id
regisleandro/clara-financas:bundles/constitution/index.md

README

# Clara Finanças

A Clara é uma aplicação de finanças pessoais orientada por conversa. A pessoa envia uma fatura, conversa com a Clara sobre o próprio dinheiro e recebe explicações rastreáveis até as transações que originaram cada valor.

O projeto é um monorepo TypeScript gerenciado por pnpm e Turborepo. Ele possui duas superfícies de runtime:

- **Control plane (`apps/web`)**: aplicação Next.js que cuida da interface, autenticação, tenants, upload de documentos e APIs auxiliares.
- **Agent plane (`packages/agent`)**: agente Eve executado separadamente. O navegador se conecta a ele diretamente, usando um JWT curto emitido pelo control plane.

Documentação complementar:

- [Deploy na Vercel](docs/deploy.md) — os dois projetos, variáveis por plano e armadilhas
- [Diagrama navegável da arquitetura](docs/arquitetura-clara.html)

## Arquitetura

```text
Navegador
  ├── HTTPS → Next.js / Control plane
  │             ├── Better Auth (sessão)
  │             ├── /api/token (JWT para o agente)
  │             ├── /api/documents/prepare (hash, deduplicação, modo)
  │             ├── /api/documents/upload (token de escrita no Blob)
  │             ├── /api/documents (registro e idempotência)
  │             └── Server Components → PostgreSQL
  │
  ├── HTTPS → Vercel Blob (PDF direto, sem passar pela função)
  │
  └── HTTPS + Bearer JWT → Eve / Agent plane
                           ├── Coordenadora Clara (gerente de conta)
                           ├── Extrator de PDFs (escrituração)
                           ├── Analista do razão (análise)
                           ├── Categorizador (guarda-livros)
                           └── Tools + HITL + schedules

PostgreSQL ← Drizzle, RLS e escopo transacional por tenant
Blob/filesystem ← PDFs; o banco guarda somente chave e hash
OpenAI/AI Gateway ← inferência; cálculos financeiros são determinísticos
```

O fluxo financeiro principal é:

```text
upload PDF → extração → lote proposto → checksum/conferência
           → aprovação explícita da pessoa → transações confirmadas no razão
```

### O PDF não atravessa a API

O arquivo vai do navegador **direto** para o armazenamento; a função só recebe
um registro com o URL, o caminho e o hash. Três motivos, em ordem de peso:

1. **O teto de 4,5 MB some.** Uma Vercel Function recusa corpos acima disso
   antes de qualquer código rodar. O limite de 20 MB que o app anunciava era,
   em produção, um 413 da plataforma sem mensagem para a pessoa.
2. **O arquivo trafega uma vez, não duas.** Antes era navegador → função →
   Blob, com a fatura inteira bufferizada na memória da função no meio.
3. **O erro chega antes do upload.** O parser roda no navegador
   (`lib/pdf-precheck.ts`): abre o PDF, confere que existe camada de texto e
   descarta o que leu. Um escaneado é recusado na hora, em vez de subir, virar
   linha no banco, virar mensagem no chat e só então falhar no extrator.

O passo `prepare` calcula o caminho a partir do tenant da sessão e do SHA-256
que o navegador computou — e, quando aquele conteúdo já é um documento do
tenant, responde `reused` e **nada sobe**. O token de escrita é emitido para
aquele caminho, com `application/pdf` e teto de tamanho; o registro re-deriva o
caminho e confere o blob com um `head` autenticado antes de gravar a linha.

Quem extrai o texto continua sendo o agente, lendo o PDF do armazenamento
(`read_pdf_pages`). Nenhum texto extraído é transmitido nem armazenado.

Sem `BLOB_READ_WRITE_TOKEN` — o desenvolvimento local — `prepare` responde
`proxy` e o arquivo volta a passar pela função, para o disco.

Um lote `proposed` não é considerado gasto confirmado. A tool `commit_batch` é o único caminho de escrita no razão e exige aprovação humana. Consultas e agregações usam `packages/ledger`, que preserva a proveniência através dos `transactionIds`.

### O estado do razão entra no contexto a cada turno

A Clara não descobre o que existe perguntando. Antes de cada turno, instruções dinâmicas do eve (`packages/agent/agent/instructions/estado.ts`) leem `loadSnapshot()` e injetam no contexto: a data de hoje em São Paulo, as faturas registradas com ciclo, vencimento, total e resultado da conferência, a cobertura do razão e quantos lançamentos seguem sem categoria.

Resolve em `turn.started`, não em `session.started`: dentro da mesma conversa a pessoa aprova uma fatura, e o turno seguinte precisa enxergar o razão já atualizado.

O eve rebaixa instruções dinâmicas a mensagens de sistema guardadas por slug, e o valor do turno substitui o do turno anterior — existe sempre um snapshot só no request, e ele não entra no histórico da conversa. O que ele não pode ser é uma segunda fonte de verdade sobre algo que muda no meio do turno: `read_batch` e `resolve_invoice_reference` reescrevem o foco da sessão depois que o bloco já foi lido. Por isso o campo se chama `activeInvoiceAtTurnStart` e o bloco declara a precedência — resultado de ferramenta do turno vence o snapshot —, e por isso ele avisa que ali só há id de fatura e de documento: um `transactionId` existe apenas no retorno de `read_batch`.

O analista e o categorizador têm cada um o seu próprio — um subagente declarado não herda nada do root, e essa duplicação é o preço do isolamento que garante que o extrator não alcance o razão.

O snapshot é deliberadamente limitado às 12 faturas mais recentes. Quando a
pessoa menciona uma fatura mais antiga, a Clara usa `list_invoices` para
recuperar o histórico sob demanda. Assim o custo de contexto não cresce sem
limite junto com a vida financeira da pessoa.

### A conversa traduz a execução

Extrator, analista e categorizador devolvem contratos estruturados validados
por Zod. A coordenadora escolhe a apresentação, mas não precisa copiar números
ou ids de uma resposta livre. Painéis financeiros falham fechado quando falta
proveniência.

Na interface, nomes de tools, raciocínio e JSON não aparecem. O stream vira
progresso orientado à tarefa — “Lendo o documento”, “Conferindo os valores” —
e cada escrita durável tem um cartão próprio com objeto, alcance e
consequência. Chamar a tool abre a decisão; não existe um “sim” em prosa
seguido de uma segunda confirmação.

Upload também não fabrica uma mensagem com `documentId`: o texto visível é
natural e o identificador viaja em `clientContext` de um turno. Senhas de PDF
usam resposta livre protegida e são removidas do histórico guardado neste
dispositivo.

### Fatura não é intervalo de datas

Um ciclo que fecha em 07/07 cobre compras de 31/05 a 30/06, e duas faturas consecutivas se tocam na virada. Por isso as ferramentas do analista aceitam `batchId`: recortar por data conta a compra da fronteira dos dois lados. O snapshot entrega os `batchId` disponíveis, então "nesta fatura" tem resposta exata.

### Identidade do comerciante

O emissor imprime o mesmo comerciante de formas diferentes a cada fatura — máscara do cartão, câmbio na descrição, número da parcela, prefixo do intermediário, invólucro do `IOF de "…"`. `merchantKey()` em `packages/ledger/src/merchant.ts` deriva uma identidade determinística, gravada em `transactions.merchant_key` no momento da proposta.

Ela resolve a parte mecânica e **deliberadamente não adivinha** que "Anthropic" e "Claude.Ai Subscription" são a mesma empresa: fusão errada some com dinheiro de um comerciante e o faz aparecer em outro, sem sinal na tela. Esse caso é aprendizado com aprovação — um conceito `MerchantAlias`, consumido por `detectRecurrences`.

### O razão por operadora e por mês

`/transacoes?vista=operadoras` cruza o gasto confirmado: operadora nas linhas, mês nas colunas, com as duas margens fechando no mesmo total. A operadora é `documents.issuer` — ela é do DOCUMENTO, não da linha, porque é a mesma informação para toda a fatura e duplicá-la abriria a chance de uma linha discordar da fatura de onde veio. O mês é o da **compra**, não o do fechamento: uma fatura fechada em julho cobre gastos de maio e junho.

A agregação é `aggregateByIssuerMonth()` em `packages/ledger/src/analysis.ts` — pura, testada e usada pelos dois lados, pelo mesmo motivo das demais: dois caminhos de cálculo acabariam divergindo entre a tela e a conversa. Cada célula carrega os `transactionIds` que a compõem, e é deles que a lista "de onde vem cada número" é montada, em vez de um segundo filtro parecido.

Na conversa, quem a alcança é `aggregate_by_month`, do analista: uma chamada devolve a série inteira, cada mês com proveniência e com a composição por operadora. Antes dela só a tela chegava a esta conta, e "quanto gastei mês a mês" exigia uma chamada por mês, adivinhando quantos meses existem — a mesma falha da fila de revisão atrás de `server-only`: a resposta existia na aba ao lado e a conversa dizia que não sabia.

Documento sem operadora identificada não some da matriz: vira a linha "Sem operadora", e a tela de revisão oferece nomeá-lo.

### Revisão manual: o que a IA não fechou

`/revisar` é a fila do trabalho que a extração não concluiu. Entram lançamentos **sem categoria** (a análise por categoria fica com um buraco), de **confiança baixa** (o extrator avisou que pode ter lido errado, e o valor conta como gasto de qualquer forma) e **sem comerciante** (a linha não se agrupa com nada). Fatura cuja soma não fechou e documento sem operadora aparecem em listas próprias — são o documento inteiro, não a linha.

"Sem categoria" quer dizer **gasto** sem categoria: pagamento de fatura e ajuste de saldo não esperam categoria nenhuma, e ficam fora do motivo (podem entrar pelos outros dois). O predicado é um só — `uncategorizedSpendCondition()` em `packages/db/src/queries/review.ts` —, compartilhado pela fila, pelo estado do razão que a Clara lê a cada turno e pela triagem do guarda-livros. Quando divergiam, a conversa afirmava "há 2 itens sem categoria" e, na frase seguinte, não conseguia listá-los: a fila devolvia outra contagem, e o item já atestado não aparecia em nenhuma das duas.

A tela mostra a **descrição crua do documento**: é contra ela que a pessoa confere, e escondê-la transformaria a revisão em adivinhação sobre o palpite da Clara.

As escritas seguem a mesma disciplina da tool `recategorize_transactions` — o caminho humano não é um atalho que escapa da auditoria. Categoria e comerciante são leitura e podem mudar, cada mudança gravando uma linha em `transaction_reclassifications` com autor `human:<id>`. Valor, data, descrição e origem seguem recusados pelo trigger; correção de valor continua sendo linha de ajuste, feita pela conversa.

O que é novo é o **atestado** (`transactions.reviewed_at` / `reviewed_by`, migração 0015): registra que uma pessoa olhou, mesmo quando nada muda. Sem ele a fila devolveria para sempre os itens cuja conclusão foi "a leitura já estava certa" — que é a conclusão mais comum — e a pessoa aprenderia a ignorá-la.

### Isolamento por tenant

O tenant é derivado da sessão autenticada; ele nunca é aceito do input do modelo ou do corpo enviado pelo cliente. O JWT contém `tenantId` e `userId`, é validado pelo canal Eve e, em cada tool, passa por `requireTenantCaller`. A função `forTenant()` define `app.tenant_id` localmente na transação para que as políticas RLS do PostgreSQL filtrem os dados na própria query.

## Estrutura do repositório

```text
clara-financas/
├── apps/
│   └── web/                 # Next.js 16, páginas, componentes e APIs
├── packages/
│   ├── agent/               # Eve, coordenadora, subagentes, tools e schedules
│   ├── auth/                # Better Auth + adapter Drizzle
│   ├── config/              # Configuração TypeScript compartilhada
│   ├── db/                  # Drizzle, schemas, migrações, RLS e tenant scope
│   ├── env/                 # Validação de variáveis de ambiente com Zod
│   ├── ledger/              # Regras, checksum e análises puras do razão
│   ├── okf/                 # Parser/validador dos bundles de conhecimento
│   ├── ui/                  # Primitivos visuais compartilhados
│   └── views/               # Contratos dos painéis e eventos HITL
├── bundles/constitution/    # Categorias, convenções e regras financeiras
├── docs/                    # Deploy e diagrama de arquitetura
├── PRODUCT.md               # Usuários, propósito e princípios estratégicos
├── DESIGN.md / DESIGN.json  # Sistema visual e tokens para agentes de interface
├── scripts/                 # Env da Vercel e reset do razão
├── turbo.json               # Pipeline do Turborepo
└── pnpm-workspace.yaml      # Workspace e catálogo de dependências
```

### Pacotes de domínio

- `@clara-financas/ledger` contém funções puras para total, categorias, comparação de períodos, recorrências, identidade de comerciante e checksum. Nenhuma delas chama um modelo.
- `@clara-financas/views` define, com Zod, os formatos de painel (`metric`, `breakdown`, `comparison`, `recurrences`, `transactions`, `invoices`, `commitments`, `proposal` e `checksum`) e a leitura de pedidos de aprovação.

  Toda linha com valor exige `transactionIds`, e as três exceções são as mesmas três coisas: `commitments` (um lembrete não saiu de lançamento nenhum), `checksum` e `invoices` (o total de uma fatura é o que o documento declara, não uma soma escolhida). A exceção não é indulgência — sem ela, a forma certa fica inexprimível e o sintoma é a pessoa pedir uma lista e não receber painel nenhum. Foi assim com a conferência, e depois com "liste as faturas mês a mês".
- `@clara-financas/okf` carrega e valida conceitos Markdown/YAML. A constituição define o contrato do domínio; aprendizados são separados e reversíveis.

## Pré-requisitos

- Node.js 24.x
- pnpm 10.x
- PostgreSQL acessível pela aplicação
- credenciais do Better Auth e, se usado, Google OAuth
- chave da API do modelo ou configuração do AI Gateway

Instale as dependências com:

```bash
pnpm install
```

As variáveis do control plane são validadas em `packages/env/src/server.ts`; as variáveis públicas do navegador ficam em `packages/env/src/web.ts`. Em desenvolvimento, o arquivo de ambiente normalmente usado pelo app é `apps/web/.env`.

Variáveis essenciais incluem `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `APP_ORIGIN`, `AGENT_TOKEN_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` e `NEXT_PUBLIC_AGENT_HOST`. Para migrações e para o `db:reset`, também `DATABASE_ADMIN_URL`. O agente tem o seu próprio `packages/agent/.env`, com as variáveis de modelo e, no modo silo, `TENANT_ID`.

Em produção, `BLOB_READ_WRITE_TOKEN` é obrigatório: o armazenamento local de PDFs só existe em desenvolvimento. A tabela completa por projeto está em [docs/deploy.md](docs/deploy.md).

## Desenvolvimento

Suba o web app e o agente em terminais separados:

```bash
pnpm dev:web
pnpm dev:agent
```

Ou execute todos os pacotes que possuem uma tarefa `dev`:

```bash
pnpm dev
```

Por padrão, o web app usa a porta `3000`. O agente Eve usa a porta definida pelo próprio ambiente de desenvolvimento.

### Banco de dados

```bash
pnpm db:generate   # gera uma migração a partir do schema
pnpm db:migrate    # aplica migrações existentes
pnpm db:push       # sincroniza o schema diretamente (desenvolvimento)
pnpm db:studio     # abre o Drizzle Studio
pnpm db:reset      # zera o razão para recomeçar os testes
```

O banco atual é PostgreSQL. Os PDFs não são armazenados como bytes no banco: em produção o navegador os envia direto ao Vercel Blob e o banco guarda apenas chave e hash; localmente, quando `BLOB_READ_WRITE_TOKEN` não está definido, são gravados em `.data/documents` pela função.

Dois papéis de banco, e a distinção é de segurança:

- `DATABASE_URL` — papel de **aplicação** (`clara_app`), sem superusuário e sem `BYPASSRLS`. É o que o app e o agente usam. Conectar como `postgres` faria a RLS virar enfeite.
- `DATABASE_ADMIN_URL` — papel de **migração**, dono do schema. Usado pelo `drizzle-kit` e pelo `db:reset`. Nunca é enviado para a Vercel.

### `pnpm db:reset`

Apaga documentos, faturas, transações, reclassificações, compromissos, notificações e os aprendizados. Preserva usuários, sessões de login, tenants e a constituição. Pede confirmação; `--yes` pula.

Usa `TRUNCATE` como dono do schema porque o trigger `clara_transactions_immutable` recusa apagar transação confirmada — afrouxar o trigger para limpar seria trocar a garantia pela conveniência.

## Testes e verificações

```bash
./scripts/dev-db.sh    # PostgreSQL local, os dois papéis e as migrações
pnpm check-types
pnpm test
pnpm test:isolation
```

O primeiro comando é pré-requisito dos outros dois, e não por conveniência:
`forTenant` recusa conectar como superusuário (a RLS viraria enfeite), então
testar exige um papel de aplicação de verdade, separado do dono do schema. O
script cria os dois, sobe o cluster, aplica as migrações e escreve as URLs em
`apps/web/.env` e `packages/agent/.env` sem sobrescrever o que já estiver lá.
`--reset` recomeça do zero.

As senhas são **geradas** na primeira execução e vivem só no `.env`, que é
ignorado pelo git — nenhuma literal no script, nem de brincadeira. Execuções
seguintes reaproveitam a que já está lá, para o banco e o arquivo não
divergirem. Como o papel nasce antes da migração, a senha fraca de bootstrap
que a migração `0001` define não chega a ser usada: é o mesmo caminho que
[docs/deploy.md](docs/deploy.md) manda seguir em produção.

Os testes de `packages/agent/tests/tools/` exercitam cada ferramenta do agente
contra esse banco — propor uma fatura, conferir, corrigir a natureza de um
lançamento, registrar, ajustar, recategorizar, revisar, descartar — e as
asserções olham o estado do banco, não o texto da resposta. Nenhum modelo
participa: uma tool é uma função, e é assim que ela é testada.

O Turborepo usa o grafo de dependências declarado nos `package.json` para ordenar builds e verificações. Para trabalhar apenas no app web e suas dependências, use filtros:

```bash
pnpm exec turbo run build --filter=web...
pnpm exec turbo run check-types --filter=web...
```

Para mudanças incrementais em CI, o modo recomendado é:

```bash
pnpm exec turbo run build --affected
```

## Comandos disponíveis na raiz

| Comando | Função |
| --- | --- |
| `pnpm dev` | Executa as tarefas de desenvolvimento via Turborepo |
| `pnpm dev:web` | Inicia somente o Next.js |
| `pnpm dev:agent` | Inicia somente o agente Eve |
| `pnpm build` | Compila os pacotes e a aplicação |
| `pnpm check-types` | Verifica os tipos em todo o workspace |
| `pnpm test` | Executa os testes dos pacotes |
| `pnpm test:isolation` | Executa os testes de isolamento do banco |
| `pnpm db:generate` | Gera migrações Drizzle |
| `pnpm db:migrate` | Aplica migrações |
| `pnpm db:push` | Faz push do schema em desenvolvimento |
| `pnpm db:studio` | Abre o Drizzle Studio |
| `pnpm db:reset` | Zera o razão preservando login e constituição |
| `pnpm deploy:setup` | Vincula a raiz ao projeto Vercel do control plane |
| `pnpm agent:link` | Vincula `packages/agent` ao projeto do agente |
| `pnpm env:production` | Envia as variáveis do control plane (`--plan` só mostra) |
| `pnpm env:agent:production` | Envia as variáveis do agente |
| `pnpm deploy` | Deploy de preview do control plane |
| `pnpm deploy:prod` | Deploy de produção do control plane |
| `pnpm deploy:agent` | Deploy do agente (`eve deploy`) |
| `pnpm deploy:check` | Dry-run de deploy Vercel |

## Implantação

No ar:

```
control plane  https://clara-financas-six.vercel.app
agente         https://clara-financas-agent.vercel.app
```

São **dois projetos Vercel**: o control plane (Next.js, na raiz) e a instância do agente (eve, em `packages/agent`). O navegador fala com o agente cross-origin, autenticado por um JWT curto emitido pelo control plane, e os dois compartilham `AGENT_TOKEN_SECRET` no modo pool atual.

O passo a passo, a dependência circular entre `APP_ORIGIN` e `NEXT_PUBLIC_AGENT_HOST`, a tabela de variáveis por projeto e as armadilhas conhecidas estão em **[docs/deploy.md](docs/deploy.md)**.

Não coloque arquivos `.env` ou segredos no repositório. O envio de variáveis é por allowlist, declarada por plano em `scripts/sync-vercel-env.ts`: uma variável fora dela não sobe — o que inclui, por construção, o papel de migração do banco.

## Decisões importantes

- **Modelo não é fonte de verdade financeira:** o modelo interpreta e escolhe tools; somas e comparações são funções determinísticas.
- **Aprovação é um gate real:** o estado durável do Eve pode esperar dias por uma decisão sem manter compute ativo.
- **Proveniência é obrigatória:** toda métrica exibida pode apontar para as transações que a compõem.
- **Contexto não se pede, se injeta:** o estado do razão entra por instruções dinâmicas antes do primeiro token, em vez de depender de o modelo lembrar de consultar.
- **Constituição versionada:** categorias e convenções são copiadas para o tenant e atualizadas quando a versão do bundle muda.
- **Aprendizado que não se aplica é anotação:** todo conceito gravado tem um consumidor — `CategorizationRule` em `apply_learned_rules`, `MerchantAlias` em `detectRecurrences`.

### Idempotência: o que está coberto e o que não está

Coberto, **por documento**: o SHA-256 do PDF com índice único `(tenant_id, content_hash)` impede o mesmo arquivo entrar duas vezes; `propose_batch` apaga rascunho anterior do mesmo documento e recusa propor um documento já registrado.

**Não coberto, por transação.** Não existe comparação de lançamentos entre lotes. O caso concreto: enviar a fatura ainda aberta, aprovar, e depois enviar a fatura fechada do mesmo ciclo. São arquivos diferentes, hashes diferentes, documentos diferentes — nada barra, e as compras que aparecem nas duas entram duas vezes no razão.

O que torna isso perigoso é que **as duas conferências passam**: o checksum prova fidelidade *documento → extração*, não consistência *extração → razão*. Cada fatura bate com o próprio total declarado e o cartão fica verde nas duas.

Enquanto não houver detecção por impressão digital do lançamento e supersessão de lote, a orientação é **não aprovar fatura parcial** — deixá-la como rascunho, que o snapshot mostra e permite retomar.

More