# HANDOFF — Marca Própria (whitelabel)

> **LEIA ISTO ANTES DE QUALQUER COISA, EM TODA SESSÃO QUE TOCAR ESTE ÉPICO.**
> E alimente este arquivo **a cada avanço** — não no fim, não "quando der".
> Handoff atualizado depois é handoff que ninguém escreveu.

---

## Protocolo desta sessão (lei, não sugestão)

Imposto por Rafael em 2026-08-13, terceira repetição da mesma correção. Palavras dele:

> *"O Claude tem mania de assumir que só porque o código funciona em testes
> automatizados o trabalho está concluído. Mas os bugs, gaps e lacunas de
> usabilidade aparecem quando USAMOS a ferramenta, e são muitas; quando deixamos
> para consertar só no fim, gasta-se um tempo 3x maior do que o de desenvolvimento
> em investigação e correção."*

**O ciclo, sem exceção:**

```
avanço → Playwright na tela como usuário → as 5 lentes → HANDOFF atualizado → próximo avanço
```

**As 5 lentes de todo teste** (nenhuma substitui outra; teste automatizado verde cobre
parte da primeira e mais nada):

| Lente | A pergunta |
|---|---|
| **Técnica** | a peça faz o que promete? |
| **Funcional** | o fluxo completo entrega o resultado ao usuário? |
| **Fundacional** | a base aguenta — schema, RLS, migration, baseline, update de clone? |
| **Doutrinária** | respeita a lei do repo — Sistema Vivo, DIRC, migrations, tenancy? |
| **Empírica** | a experiência real é BOA? um leigo entende? está claro? |

**Âncora de arquitetura:** o público-alvo principal instala o CRM numa **VPS da
HostGator** pelo `hostgator-setup-kit/`, com **Supabase cloud**. Toda decisão se
avalia contra isso primeiro — não contra uma hospedagem gerenciada, não contra o
laptop. E a pergunta que quase sempre é esquecida: **como isto chega a um clone
que JÁ RODA e vai atualizar?**

---

## Onde estou

| | |
|---|---|
| **Worktree** | `/Users/EXEMPLO/DeskcommCRM-marca` |
| **Branch** | `feat/marca-propria`, criada de `origin/main` @ `f9abedd0` |
| **Banco** | Supabase local `127.0.0.1:54321` — **compartilhado com outras sessões**, checar antes de DDL |
| **Blueprint** | https://claude.ai/code/artifact/00000000-0000-4000-8000-000000000000 |

> ⚠️ O worktree principal (`/Users/EXEMPLO/DeskcommCRM`) é de **outra sessão** —
> mudou de `fix/alertas-de-seguranca-github` para `fix/issues-triadas` no meio desta.
> Não commitar lá.

---

## Estado das fases

**A ordem mudou depois da reancoragem.** A antiga fazia sentido para uma hospedagem
gerenciada, onde nada chega ao usuário sem deploy. Sob a âncora VPS, o valor chega
antes por outro caminho: o que o comprador percebe primeiro não é upload de logo, é
**a interface não parecer a nossa** — e cor é o eixo mais barato, mais visível, e o
único que a doc de venda declara impossível hoje.

> **A numeração divergiu no meio do épico, e esta tabela é a que vale.** A versão anterior
> reservava a **Fase 3** para "upload de logo" e chamava a marca por organização de Fase 2 —
> enquanto os commits, o plano de execução e as seções deste arquivo já usavam outra contagem
> (Fase 2 = a tela `/admin/marca`, Fase 3 = marca por organização). Duas numerações para o mesmo
> trabalho é como uma sessão conserta a fase errada. **O upload de logo saiu do épico** e virou
> dívida declarada (D3 abaixo): o logo continua vindo do `.env`, e nenhuma tela o edita.

| # | Fase | Estado |
|---|---|---|
| **0** | **Encoding do `.env` — bloqueador medido** | ✅ `c3764437` |
| **1** | Derivação de cor (rampa + contraste) e a cor da instalação chegando à tela | ✅ 1a `e318d1a7` · 1b `c4395adf` + `f619f23d` · 1c (tabela `platform_branding` + resolvedor) `0872214d` |
| **2** | Tela `/admin/marca` — ver, mudar, e ver o estado quando a cor não pinta | ✅ `50c20179` |
| **3** | Marca **por organização** (nome + cor), com tela própria e CSS escopado | ✅ `0513755c` · `032ba43a` · `a8ec1d99` · `55b6f6ea` · `6b33a258` |
| **4** | As saídas **sem DOM** — e-mail, remetente, PDF de LGPD, autenticador, suporte | ✅ `a6acb9f1` |
| **5** | Favicon em runtime, barra do navegador, e-mails de acesso do Supabase | ✅ `71616d78` + `741c4ec8` |
| **6** | Documentação, mapa vivo, jornada, checklist e os números velhos de CI | ✅ *(esta sessão)* |

**Fora do épico, que ele expôs e outra frente resolveu:** dar `image:` ao worker (a atualização
não alcançava o runtime do agente). Registrado em `docs/runbooks/remediar-worker-congelado.md`.

---

## O que já foi MEDIDO (não presumido)

Cada linha aqui foi verificada lendo o arquivo. Onde diz *(workflow)*, o número veio
de um agente que rodou o cálculo — e está marcado de propósito.

### A premissa do pedido estava parcialmente errada

- **`lib/branding.ts` já existe** — nome + logo por `.env`, lidos em runtime.
  Deliberadamente **não** `NEXT_PUBLIC_*`: a imagem Docker é pré-buildada e a marca
  do revendedor nunca apareceria. **8 call sites; 6 rodam sem organização resolvida.**
- **`docs/white-label.md` já promete isso em público**, em 3 idiomas, e lista os
  buracos exatos: *cores/fontes/tema não configuráveis*, *marca por instalação e não
  por organização*, *textos e e-mails seguem o padrão do produto*.
- **`docs/design-system/screen-flow/03-screen-inventory.md:149`** já inventariou a
  rota `/app/settings/tenant/branding` com `<BrandingForm>`, prioridade P2.
- **`app/design/lib/tokens.ts`** tem 5 paletas completas (sage/clay/mist/plum/olive),
  accent de 11 stops, neutros light e dark **desenhados separadamente**.

### Defeitos pré-existentes encontrados no caminho

| Defeito | Evidência | Fase que conserta |
|---|---|---|
| **Gate de marca verde enquanto a marca vaza** — `/Deskcomm/` case-**sensitive** | `tests/unit/branding.test.ts:90`; passam `support@deskcomm.com.br` (`app/account-suspended/page.tsx:17`), `suporte@deskcomm.app` (`app/app/settings/billing/page.tsx:26`), `deskcommcrm-recovery-codes.txt` (`components/auth/RecoveryCodesPanel.tsx:34`) | 1 |
| **Colisão de cor Δ=0,0° no tema escuro** — `--color-success` é a mesma string de `--color-accent-400` | `app/globals.css:167` e `:193`, ambos `#82a077` | 1 |
| **Corrida no `settings` jsonb** — SELECT→spread→UPDATE sem `.select()`; `visibility_mode` mora no mesmo jsonb | quem ainda faz assim: `git grep -n "update({ settings" -- app lib workers` (o `updateTenant.ts`, citado aqui até o PR #1209, deixou de escrever `settings`) | 3 |
| **`[data-theme="light"]` não existe** — `:root` casa só `<html>`, então tema claro não é escopável em subárvore | `grep -c 'data-theme="light"' app/globals.css` = 0 | 1 |

### Topologia real do público-alvo

- **Supabase é cloud**, provisionado pela Management API (`hostgator-setup-kit/supabase-provision.sh`).
  Storage não consome disco da VPS, mas consome **cota do plano do cliente**,
  competindo com `whatsapp-media`. Plano grátis: 2 projetos por usuário.
- **O scheduler da VPS agenda toda rota de `app/api/v1/cron/`** — o crontab mora em
  `docker/scheduler/entrypoint.sh`, e quem mantém as duas listas iguais nas duas direções
  é `tests/unit/cron-routes-scheduled.test.ts`. O anti-morte por cron **existe** para o
  público principal.
- Serviços do compose: `app`, `worker`, `waha`, `redis`, `srh`, `scheduler`, `caddy`.
  **Dois processos Node** (app + worker), não N lambdas — isso muda a escolha de cache.
- A única policy de escrita em `organizations` é `orgs_write_platform_admin`
  (`supabase/baseline.sql:3415`, `FOR ALL`). Pelo client de sessão o UPDATE de um
  admin de tenant casa **0 linhas e o PostgREST devolve sucesso**. O molde correto
  (admin client + gate de papel + filtro explícito) está em `updateTenant.ts:31-50`.

---

---

## Fase 0 — o bloqueador, medido por mim

### O defeito

`hostgator-setup-kit/install.sh:436` grava o `.env` com aspas simples e escape `'\''`.
Para `APP_NAME = Sant'Ana Odontologia` isso produz `APP_NAME='Sant'\''Ana Odontologia'`,
e o **Docker Compose recusa ler o arquivo inteiro**:

```
failed to read .env: line 1: unexpected character "\" in variable name "\''Ana Odontologia'"
docker compose config -> rc=1    ps -> rc=1    pull -> rc=1
```

**Controle positivo:** o mesmo arquivo, uma variável mudada (sem apóstrofo) → rc=0.
**Controles negativos:** cifrão e cerquilha passam (rc=0) — o defeito é específico do apóstrofo.

Onde morde: `APP_NAME` é a **última** pergunta da entrevista (`install.sh:996`), sem
validador; o `.env` é escrito em `:1303`; o baseline é aplicado e o dono é criado por
caminhos que **não usam compose** (passam) — e só então `dc pull`/`dc up -d` (`:1473`)
morrem. O comprador fica com Supabase provisionado, schema aplicado, admin criado, e um
erro sobre "variable name" que não aponta para nada que ele digitou. Como `dc()` não passa
`--env-file`, **todo** comando do kit passa a falhar: `healthcheck.sh`, `update.sh`,
`backup.sh`, e o agente de 5 em 5 minutos.

"Sant'Ana", "D'Ávila", "Espaço D'Or" são nomes de empresa brasileiros comuns. E vale para
`OWNER_PASSWORD` com apóstrofo.

### Por que nenhum gate pegava

O round-trip do `.env` em `test-validators.sh:303-325` exercita **só** o `load_env` do
bash, e o fixture não tem apóstrofo interno. Nenhum teste do kit roda `docker compose`
contra um `.env` gerado. Ponto cego em dois eixos ao mesmo tempo: o **consumidor** não
coberto e o **caractere** não coberto.

### São TRÊS consumidores, não dois

1. `load_env` (`_common.sh:252-276`) — parsing manual com `printf -v`, **não** `source`.
2. `docker compose` via `env_file: .env` (`docker-compose.prod.yml:34,71`).
3. `source .env && curl …` — receita real em `hostgator-setup-kit/README.md:143`.

### A solução, escolhida por medição

18 combinações (6 valores × 3 consumidores):

| encoding + leitor | `load_env` | `source` | contêiner |
|---|---|---|---|
| atual (aspas simples + `'\''`) | 6/6 | 6/6 | **4/6** |
| aspas duplas + `load_env` atual | **3/6** | 6/6 | 6/6 |
| **aspas duplas + `load_env` com patch** | **6/6** | **6/6** | **6/6** |

`envq` passa a usar aspas duplas escapando `` \ " $ ` ``, e o `load_env` desfaz esse
escape. O ramo de aspas simples **fica** — clone que atualiza não reescreve o `.env`.

> ⚠️ **Nota sobre o meu instrumento:** o contador automático de falhas do meu script
> media só o contêiner (incrementava dentro de `$( )`, que é subshell). As linhas
> individuais são a verdade; a tabela acima veio delas, não do contador.

---

## Achados fora do escopo, que o épico expôs

| Achado | Evidência | O que muda |
|---|---|---|
| **O worker não tem `image:`, só `build:`** — único dos 7 serviços | `docker compose -f docker-compose.prod.yml config` resolvido pelo próprio Docker | `install.sh` roda `dc up -d` sem escopo → **a VPS compila**, contra o que `hostgator-setup-kit/README.md:91` promete. E `update.sh` (`dc pull` + `up -d` sem `--build`) → **o worker segue com código velho**. Consequência é inferência da semântica do Compose; falta observar numa VPS |
| **Regra para o épico** | — | Trabalho agendado da marca vai em `app/api/v1/cron/*` batido pelo `scheduler`, **nunca** no worker — é o único componente que não recebe código novo |
| **Apêndice do baseline: bucket chega, mudança de policy some** | Reprodução em Postgres descartável: `ERROR: policy already exists` e o statement seguinte entra | Todo bloco de policy do apêndice nasce com `drop policy if exists`. Todo bucket com `on conflict (id) do …` |
| **Cota do Supabase é do cliente e ninguém a mede** | `docs/SETUP.md:76` (1 GB, e é a única ocorrência no repo — está no guia de **dev**) | O bucket de marca disputa 1 GB com toda a mídia de WhatsApp, que **não tem poda por idade** (`media_retention_days` é campo morto). Teto de upload **512 KB**, não 2 MB, e apagar o objeto anterior na troca |
| **A organização sempre nasce "Minha Empresa"** | `install.sh:1441`, literal — logo depois de o instalador perguntar `APP_NAME` | O `install.sh` não ganha pergunta nova; passa a **usar** o que já tem |
| **Não existe favicon** | `public/` só tem `llms.txt`; nenhum `app/icon.*` | A aba não tem marca nenhuma, nem a nossa |

---

## A doutrina do repo está velha em dois pontos (medido 2026-08-13)

O `CLAUDE.md` é a lei deste repo, e ele mesmo avisa que um destes números "já apodreceu
duas vezes". Apodreceu de novo:

| O `CLAUDE.md` diz | Medido |
|---|---|
| 4 checks obrigatórios na `main`: `verify, build-and-size, invariants, e2e` | **5** — `gh api …/branches/main/protection` devolve `verify, build-and-size, invariants, e2e, imagens-ok` |
| "**37 das 39 specs**" Playwright | **45 specs no disco** (`ls tests/e2e/*.spec.ts \| wc -l`) |

Não corrigi aqui: o `CLAUDE.md` é doutrina compartilhada e mexer nele no meio de um épico
conflita com outras sessões. **Fica como item próprio.** E a régua honesta para "quantas
rodam" não é contar à mão — é o gate `tests/unit/e2e-cobertura-completa.test.ts`, que
está **verde**: toda spec do disco está declarada em alguma das três listas.

> Tentei contar as listas com `sed` e obtive `SPECS_PARTE_1: 45` **e** `FORA_DO_CI: 45` —
> impossível, o delimitador não casou. Não repasso número que não medi.

## Uma armadilha da suíte e2e que vale além deste épico

`scripts/seed-e2e-system-update.ts:52-68` **promove `e2e-admin@deskcomm.test` a
`platform_admin`** — insere, e reativa se estiver revogado. **Nenhum seed do repo
revoga**: o único `revoked_at` que existe é em `seed-e2e-agente-mcp.ts`, sobre tokens de
API. Como as duas partes do job `e2e` compartilham o mesmo banco sem reset, **toda a
parte 2 roda com esse admin já promovido**.

Consequência para qualquer spec que queira provar comportamento de **admin de tenant**:
usar o `e2e-admin` mede o produto errado e passa verde. Por isso a Fase 3 cria
`e2e-marca-admin@deskcomm.test`, sem linha em `platform_admins`, e a spec **afirma a
precondição** (zero linhas ativas) antes de medir — senão verde é resultado de
instrumento morto.

---

## Bugs achados executando

### 🔴 BUG-01 — o anel de foco perde o contraste no tema escuro com marca escura

**Achado na tela, não em teste.** Dev na 3111, `APP_ACCENT_HEX="#0f172a"` (navy), medido
com `getComputedStyle` em `/login`:

| par | Sage (controle) | navy | piso |
|---|---|---|---|
| claro: `accent-500` × bg | 3,79 | 10,77 | 3,0 ✅ |
| claro: `accent-500` × surface-elevated | 3,60 | 10,22 | 3,0 ✅ |
| **escuro: `accent-400` × bg** | 4,58 | **2,86** | 3,0 ❌ |
| **escuro: `accent-400` × surface-elevated** | — | **2,37** | 3,0 ❌ |

**Causa raiz:** `globals.css:376,381` — o anel de foco usa stops **fixos e diferentes por
tema** (500 no claro, 400 no escuro), e esse stop não passa pela caminhada de contraste
que governa o accent de ação. A rampa é única, então `accent-400` vale o mesmo nos dois
temas e no escuro é comparado contra um fundo quase preto.

**Por que nenhum teste pegou:** os testes exercitam a **função de derivação**, não os
**pares que o produto realmente pinta**. É a diferença entre cobrir caminhos e cobrir o
call site.

**Gravidade:** WCAG 1.4.11 é nível AA, e o indicador de foco é o exemplo canônico da
norma. O produto **cumpria antes do épico** e deixaria de cumprir por causa dele.

**Estado:** conserto em execução, com a guarda que fecha a classe inteira (todos os pares
papel × superfície × 16 sementes) e controle positivo de que a Sage não se move.

### ⚪ BUG-02 — 12 chunks `/_next/static/` dão 403 no browser em dev

Aparece no **controle também**, então não é do épico. `curl` no mesmo chunk devolve
**200**, e `/^\/_next\//` está em `PUBLIC_PATHS` — não é o proxy. Assinatura de Turbopack
gerando chunk sob demanda. **A confirmar no build de produção**, onde os chunks são
estáticos; se sumir lá, é artefato de dev e não vale conserto.

---

## Prova em tela — Fase 1b (dev, porta 3111)

| o que | controle (sem var) | navy `#0f172a` |
|---|---|---|
| `/login` | HTTP 200 | HTTP 200 |
| bloco de marca no `<head>` | — | **1 bloco com `--color-accent`** |
| `--color-accent` claro | `#506d48` | **`#0f172a`** ← a semente sobrevive intacta |
| `--color-accent` escuro | `#82a077` | `#828a9d` |
| alternância de tema sobrevive | sim | **sim** |
| alternância é reversível | sim | **sim** |
| `[data-theme="light"]` escopa em subárvore | **sim** (`#faf9f6` sob root escuro) | sim |
| colisão accent × success (escuro) | **COLIDE** | **ok** ← a reconciliação funciona |
| accent × surface (claro) | 5,80 | 17,85 |

**Os dois resultados que mais importam:** a navy **permanece `#0f172a`** — é a ancoragem
por papel funcionando na tela, não no papel — e a colisão accent/success do tema escuro,
que o produto tem hoje, **passa a `ok`** quando a reconciliação roda.

### Instrumento meu que falhou três vezes nesta rodada (e o que aprendi)

1. `import { chromium } from "playwright"` — o repo tem `@playwright/test`, não `playwright`.
   ESM resolve a partir do diretório **do arquivo**, não do cwd.
2. Parser de hex assumindo 6 dígitos — `--color-surface` no claro é `#fff` e
   `--color-accent-soft` no escuro é `#82a07729`. Devolvia `None` em silêncio.
3. **O mais grave:** medi o anel de foco com `accent-500` **nos dois temas**, quando o
   produto usa 400 no escuro. O número que reportei primeiro (1,61) era de um token que o
   produto não pinta. O defeito é real, mas o valor certo é **2,86**.

Todo número da tabela acima é de run posterior a essas correções.

---

## Fase 0 — ENTREGUE (`c3764437`)

**O conserto.** `envq` passa a aspas duplas escapando `` \ " $ ` ``; o ramo de aspas
duplas do `load_env` desfaz esse escape (sentinela `\001` para não reprocessar `\\`).
O ramo de aspas simples **fica** — clone que atualiza não reescreve o `.env`.

**Prova que eu mesmo rodei**, com o `envq` real do disco (não uma cópia):

| valor | linha gravada | `load_env` | `source` | contêiner |
|---|---|---|---|---|
| `Sant'Ana Odontologia` | `APP_NAME="Sant'Ana Odontologia"` | ok | ok | ok (rc=0) |
| `Casa "Bela" #1` | `APP_NAME="Casa \"Bela\" #1"` | ok | ok | ok (rc=0) |
| `se$nha P$ss` | `APP_NAME="se\$nha P\$ss"` | ok | ok | ok (rc=0) |

`pnpm test:shell` medido por mim: **EXIT=0 · 232 ✓ · 0 ✗** (era 201). Os casos novos
cobrem os três consumidores contra uma lista única de 8 valores, mais **controle
positivo** (o formato antigo é recusado pelo Compose — é isso que prova que o teste
vigia) e retrocompatibilidade com `.env` no formato velho.

Sabotagem, previsto vs medido: **9/9, 9/9, 1/1, 24/24** (a última é guarda de vacuidade).

**Residual conhecido e documentado:** o parser do Compose não desfaz `` \` `` dentro de
aspas duplas, então valor com crase chega feio ao contêiner. Mantido de propósito — a
alternativa é o `source .env` do README **executar** o que está entre crases.

**Revisão que fiz do trabalho do subagente:** ele alterou 7 asserções pré-existentes.
Conferi uma a uma — são todas troca de aspas na asserção (`grep -qx "X='v'"` →
`grep -qx 'X="v"'`), acompanhando o formato novo, ainda ancoradas com `-qx`/`^`.
Nenhuma afrouxada.

> ⚠️ **Ainda não provado:** instalação real numa VPS. Isto cobre o encoding e seus três
> leitores, não a jornada do comprador ponta a ponta.

---

---

## Decisões das 6 lentes novas (43 achados brutos → 6 aprovados)

### PACKAGING: **NÃO.** Não vira monorepo, não vira pacote npm.

Regra de extração é ≥2 consumidores independentes; medido **1** — os 8 importadores de
`lib/branding` estão todos em `app/` e `components/`, e o worker não renderiza marca
nenhuma. Custo do contrário: 2 Dockerfiles (`--frozen-lockfile` quebra em workspace),
`output: "standalone"` (o artefato vira `.next/standalone/apps/web/server.js` e quebra o
`COPY`/`CMD`), 2 tsconfig, 2 aliases de vitest, e **53 testes de arquitetura ancorados em
caminho top-level cujo helper devolve `[]` em diretório inexistente** — ou seja, trocar
risco de gate vacuamente verde por zero ganho.

**O packaging que de fato falta é outro: um emissor de build-time multi-formato.** A marca
atravessa 4 fronteiras de processo e **3 não consomem TypeScript**: o `StyleSheet` do
`@react-pdf` (não lê CSS var), o HTML inline dos e-mails, e os templates Go do Supabase
Auth. ~80 linhas + um teste que reprova quando o gerado diverge do commitado. Fase 4.

**Imagem Docker por marca fica proibida — agora com prova:** `update.sh:158-159` grava
`APP_IMAGE` no `.env` **incondicionalmente**, e `set_env_var` faz `grep -v` + append sem
merge. A imagem do revendedor seria substituída pela upstream num update de rotina, em
silêncio. O desenho atual (imagem genérica + marca em runtime) está certo — o trabalho é
**protegê-lo** com um caso em `update-guard.test.sh`.

### Os 6 ângulos aprovados

| # | Ângulo | Fase | Custo |
|---|---|---|---|
| 1 | **Forma do que se grava** — jsonb guarda ENTRADA (`{format, algo, semente_hex}`), nunca saída; schema *loose com catchall* (exceção declarada ao `.strict()` do repo); resolvedor **nunca lança** | 1 | ~3h |
| 2 | **O gate de derivação mede o token errado**, em 3 eixos | 1 | ~1,5d |
| 3 | **A varredura de marca não alcança onde a marca sai** | 4 (parte na 1) | ~7h |
| 4 | **Emissor multi-formato** (o packaging real) | 4 | ~2d |
| 5 | **Diagnóstico emite FORMA, nunca IDENTIDADE** | 1 | ~3h |
| 6 | **Billing entrega o cliente do revendedor para nós** | 0/1 | ~1d |

**Ângulo 1 — por que é irreversível.** Ninguém tinha medido o que o **código velho** faz
com o jsonb do código novo. E o rollback põe código velho sobre schema novo *por
construção*: `update.sh` aplica o baseline antes de puxar a imagem, e `agent.sh` reverte
só `APP_IMAGE`. Com `.strict()`, chave desconhecida **lança** — e como `branding()` é
chamado em `app/layout.tsx`, um throw ali é **500 em todas as telas**. O envelope
`{format, algo}` nasce na Fase 1 mesmo sem tela de import/export: é a única superfície que
atravessa instalações, e retrofit é impossível (sem ele, toda linha existente vira "algo
desconhecido").

**Ângulo 2 — a régua ordena invertido.** Simulação de dicromacia (matrizes Machado 2009)
reproduzida de forma independente:

| par | ΔH | ΔE deuteranopia |
|---|---|---|
| oliva `#7f8c3a` × warning | **44,7°** (passa com folga) | **0,0231** |
| verde-água `#1abc9c` × success | **27,2°** (mal passa) | **0,1264** |

Maior ângulo, pior separação. Troca: **ΔE em OKLab sobre a cor simulada**, piso ≥ 0,05.
Mais duas correções: **(b)** quem se move são as *nossas* semânticas, nunca o accent — a
cor da marca é a única que não nos pertence; **(c)** o piso é por **papel × superfície**,
não pela semente — medido na Sage, `accent-600` vs bg = 5,51 (o que o piso checa) mas o
anel de foco usa `accent-500` e dá **3,79**; com a semente no piso de 3,0, o anel pousa em
~2,07 **com o gate verde**. Por isso os pares saem extraídos do `globals.css`, nunca
listados à mão.

### Os que NÃO valem (isto impede o épico de inchar)

Monorepo · `@deskcomm/tokens` no npm · Turborepo/Nx · style-dictionary · regra eslint de
fronteira · **regressão visual por screenshot em qualquer volume** (não pega nenhum dos
defeitos medidos, e custa 136 MB num repo cuja estratégia é otimizar para fork) · Percy /
Chromatic / Argos (check pago = vermelho permanente em fork) · Storybook (os defeitos são
de container e tema, não de componente) · `prefers-contrast` · domínio por organização ·
DSN de Sentry por org · página de status pública · phone-home · cota de logo ·
"remover o selo é pago" · **seletor de fonte** (Atkinson Hyperlegible foi escolhida pelo
Braille Institute por legibilidade; trocá-la não muda percepção de marca e só piora a
leitura do operador).

### Correção de um erro meu

**Eu afirmei que `app/design` é rota pública sem auth. É falso.** Minha sonda procurou em
`middleware.ts`, que **não existe** — o Next 16 renomeou para `proxy.ts`, que existe e
está na raiz. `/design` não está em `PUBLIC_PATHS` (`lib/auth/public-paths.ts:5-27`), e
`proxy.ts:32` redireciona quem não bate na allowlist. A rota é **autenticada**; o
problema real é vazamento cross-tenant, e o conserto é `notFound()` fora de dev (20 min),
não remover da build. Grep sobre arquivo inexistente devolve vazio, indistinguível de
"não achei" — [[feedback_instrumento_quebrado_devolve_zero]] outra vez.

### A pergunta que ninguém tinha feito: **quantas identidades o produto precisa modelar?**

`lib/lgpd/pdf-renderer.tsx:277` imprime, no documento entregue ao **titular de dados**:

> `DeskcommCRM · Relatório LGPD Art. 18 II · DPO: contato via canal oficial do controlador`

Dois defeitos. O primeiro é o vazamento de marca (ângulo 3 pega). O segundo ninguém tocou:
**o sistema já sabe o DPO e não o imprime** — `lib/env.ts:134` tem `LGPD_DPO_EMAIL` e
`lib/lgpd/sla-alarm.ts:93` resolve `organizationDpoEmail || env.LGPD_DPO_EMAIL`.

E o buraco que o whitelabel abre: em toda outra superfície, marca = **quem vende**. No
relatório de Art. 18, a identidade correta é o **controlador** — a empresa cujos dados
são. Se a Fase 4 tratar esse rodapé como "mais uma saída sem DOM" e trocar o nome pela
marca do revendedor, ela **piora** o defeito: nomeia o revendedor como controlador num
documento que responde a um direito legal, quando o controlador é o cliente dele.

**DECIDIDO (2026-08-13): quatro papéis, três identidades de marca, zero campos novos.**

`organizations` **já** separa `legal_name` (NOT NULL) de `display_name` — medido no
`baseline.sql`. A identidade jurídica já está modelada; ninguém a estava usando.

| Papel | Onde vive | O que carrega |
|---|---|---|
| Nós (o produto) | selo removível | só isso |
| **Instalação** | `platform_branding` | marca visual pré-login |
| **Organização — marca** | `display_name` + `settings.branding` | o que aparece na tela pós-login |
| **Organização — controlador** | `legal_name` *(já existe)* | identidade jurídica no PDF de LGPD |

**Por que `controlador` não é campo novo:** DIRC responde **I — Integrar**. O controlador
de dados é a organização, e a organização já declara sua razão social. Criar
`controlador_nome` seria duplicação sem source of truth (anti-pattern nº 2 do CLAUDE.md).

**Consequência para a Fase 4:** o rodapé do PDF de LGPD passa a imprimir `legal_name` +
DPO resolvido (`organizationDpoEmail || env.LGPD_DPO_EMAIL`), **não** a marca. Trocar o
nome pela marca do revendedor ali pioraria o defeito — nomearia o revendedor como
controlador num documento que responde a um direito legal.

**O caso previsto:** revendedor hospeda para um cliente que atende sob outra razão social.
Resolve-se com **uma organização por razão social**, que é o que a tenancy já modela — não
com um campo a mais. Se algum dia uma organização precisar declarar controlador diferente
da própria razão social, aí sim é campo novo, e a razão estará escrita aqui.

---

## Achados novos confirmados por mim

| Achado | Evidência |
|---|---|
| **Todo projeto Supabase do revendedor nasce chamado "DeskcommCRM"** | `install.sh:918` usa `${APP_NAME:-DeskcommCRM}`; `APP_NAME` só é coletado em `:1024` — **106 linhas depois** |
| **O primeiro e-mail que o cliente do revendedor recebe diz o nome do nosso produto** | `supabase/templates/confirmation.html:4` e `recovery.html:4`; e **nenhum script sobe esses templates** — só `config.toml` (Supabase local) e um teste |
| **A tela de dinheiro entrega nosso contato ao cliente do revendedor** | `lib/navigation/registry.ts:453` (porta de 1ª classe, "Billing") → `app/app/settings/billing/page.tsx:26` mostra `suporte@deskcomm.app` |

---

---

## Fase 1a — ENTREGUE (`e318d1a7`): derivação de cor

`lib/branding/rampa.ts` + `contraste.ts`, funções puras, zero dependência nova.
37 testes. **Números que eu reproduzi de forma independente**, não repassei:

| medição | subagente | eu |
|---|---|---|
| `#0f172a` croma | 0,039824 | **0,039824** |
| `#1a1f36` croma | 0,044430 | **0,044430** |
| cinzas (`#808080`, `#000000`) | ~0 | **0,000000 exato** |
| calibração Sage, Δ por canal | ≤ 2/255 | **Δmax=2**; stops 500/600/700/900/950 exatos |

Gates medidos por mim: `typecheck` 0 · `lint` 0 · `test:unit` **4182 passed (+37)**.
As 5 falhas visíveis são de `lib/ai/dispatcher/rate-limit.test.ts` e são
**pré-existentes** — provado removendo os arquivos novos do disco e rodando com a árvore
limpa no HEAD: falha 5/5 igual.

### Dois números de croma, não um — e a diferença decide o épico

`#0f172a` mede **0,039824** e `#1a1f36` mede **0,044430**: as duas navies caem em lados
opostos de 0,04 por **0,0046**. Com um gatilho único em 0,04, a navy que motiva o épico
inteiro perderia a marca e receberia Sage. Então `LIMIAR_ACROMATICO = 0,01` (cinza mede
0,000000 exato — 40× de margem) é o **gatilho**; `PISO_DE_CROMA = 0,04` continua como
**asserção** sobre o accent que resta.

### A sabotagem que reprovou menos rendeu mais que o teste

Previsto 4, medido 2 na sabotagem de `CURVA_C`. Investigado: o modelo mental era do
autor, não do teste — a caminhada de contraste e a reconciliação são governadas por
**lightness**, e `CURVA_C` só mexe em **croma**.

E o achado maior: **a sabotagem da ancoragem NÃO é pega pela calibração Sage**, porque a
semente Sage tem L exatamente igual a `ESCADA_L[6]` — ancorar por lightness dá o mesmo
resultado ali. Por isso os testes de ancoragem existem separados; sem eles, a catraca
principal daria falso verde justamente na regra mais importante.

### Meu instrumento me traiu (de novo)

Escrevi um teste de verificação chutando o formato de retorno (`.stops`, `.croma`),
recebi `NaN`/`undefined`, e teria concluído que o código estava errado. `Rampa` é uma
**tupla de 11 strings**. Li a assinatura, refiz com controle negativo, e os números da
tabela acima são do run correto.

### Dívida declarada

Nada consome esses módulos. **O invariante 1 (nada é ilha) NÃO está satisfeito** — está
escrito no commit. A Fase 1b fecha isso.

---

## Defeitos pré-existentes anotados no caminho (fora do escopo, não esquecidos)

| Defeito | Evidência |
|---|---|
| `lib/ai/dispatcher/rate-limit.test.ts` falha 5/5 por timeout de 15s | Reproduzido com árvore limpa no HEAD |
| `--color-accent` usado como **texto** fora do CSS mede **4,02** no tema escuro (< 4,5 de WCAG 1.4.3) | `app/app/ai/followups/[id]/_components/nodes/nodeVisuals.ts:32` (`bg-accent-soft text-accent`) e `.ds-badge--accent`. Consertar move o accent escuro do produto de 400 para 300 — **decisão de design system, não deste épico** |

---

## Fase 1c — ENTREGUE (working tree, NÃO commitada): a marca sai do `.env` e vai para o banco

**Trio de migration completo** (a doutrina exige os três juntos):

| Artefato | Arquivo |
|---|---|
| migration | `supabase/migrations/20260813090000_0155_marca_da_instalacao_no_banco.sql` |
| apêndice idempotente | `supabase/baseline.sql` (+95 linhas, **0 remoções**, antes do `notify pgrst` final) |
| MANIFEST | `supabase/migrations/MANIFEST.md` (linha nova) |

**Numeração, com a medição que a justifica** (as duas réguas são independentes):

- `NNNN = 0155` — maior existente **em TODAS as branches locais** é `0154`. Medido com o
  mesmo laço do `loop/hooks/check-migration-triple.sh` (`git branch` × `git ls-tree`), não
  só neste checkout: `0155` não aparece em branch nenhuma.
- `timestamp = 20260813090000` — maior existente é `20260811210000`. A ordem de aplicação é
  por timestamp, e ele **não** é monotônico com o `NNNN` neste repo (a `0119` tem timestamp
  maior que a `0142`), então os dois se medem separado.

### Código

| Arquivo | O quê |
|---|---|
| `lib/branding/instalacao.ts` | **novo** — leitura, semeadura, memo com TTL, estado do fallback |
| `lib/branding/resolve.ts` | `camadaDaInstalacao()` ao lado de `camadaDoAmbiente()` — o resolvedor foi **estendido**, não reescrito |
| `app/layout.tsx` | pilha `[banco, env]`; `generateMetadata` virou `async` e lê a marca resolvida |
| `app/actions/settings/updateBranding.ts` | **nova** server action (gate `is_platform_admin`) |
| `lib/schemas/settings.ts` | `platformBrandingSchema` |
| `lib/audit/actions.ts` + `components/admin/audit/action-codes.ts` | `platform_branding.updated` nas **duas** listas |
| `tests/unit/branding-instalacao.test.ts` | **novo** — 26 casos (decisões, sem I/O) |
| `tests/invariants/marca-da-instalacao.test.ts` | **novo** — 14 casos (privilégio, comportamento, CHECK, trigger) |

### Prova em Postgres descartável (`pgvector/pgvector:pg17`, o harness do repo)

```
==> modo INSTALL: aplicando baseline.sql com ON_ERROR_STOP=1
psql:<stdin>:4084: WARNING:  "wal_level" is insufficient to publish logical changes
    ✓ install ok
==> modo UPDATE: re-aplicando baseline.sql sem ON_ERROR_STOP (idempotência)
    ✓ update ok (re-apply terminou; erros tolerados por contrato)
```

O `update` emite **301** erros — todos do corpo do `pg_dump` (PK, índice, FK e policy
"already exists"), que o harness tolera por contrato. Linhas do `update` citando
`platform_branding`: **zero**, medido com
`awk '/^==> modo UPDATE/,/update ok/' | grep -ci platform_branding`, não a olho.

**Como sei que o delta do meu bloco é 0 sem ter medido o HEAD:** a sabotagem 2 move a
contagem de **301 → 302**, e a linha a mais é exatamente a que nomeia a tabela. O par
"zero menções + a sabotagem produz uma" prende o número dos dois lados; a contagem no
HEAD eu **não** rodei, e não a afirmo.

### Gates (medidos por mim, com o `.env.local` fora do disco)

| Gate | Base | Depois |
|---|---|---|
| `pnpm typecheck` | 0 | **0** |
| `pnpm lint` | *(não medi no HEAD)* | **0 erros / 241 warnings** no repo. A medição que vale é direta: `npx eslint` nos **13 arquivos criados/alterados** devolve **saída vazia** — 0 erro e 0 warning meus. Os 241 são dívida de estilo pré-existente; não afirmo que eram 241 antes porque não rodei no HEAD |
| `pnpm test:unit` | 378 files / 4284 tests | **379 / 4310, EXIT=0** (+1 arquivo, +26 casos — bate exato) |
| `pnpm test:db` | — | **101 files / 732 passed · 1 expected fail · 1 skipped, EXIT=0** |

### Sabotagem — previsto vs medido

| # | O que sabotei | Previsto | Medido |
|---|---|---|---|
| 1 | `revoke all … from anon, authenticated` fora do baseline | 5 | **5** |
| 2 | `create table if not exists` → `create table` (idempotência) | 1 erro no `update`, **0** reprovações | **1 erro, 0 reprovações** (301 → 302) |
| 3a | `precisaSemear` sempre `"nao"` | 2 | **2** |
| 3b | guarda `if (!linha.seeded_from_env) return "nao"` removida | 1 | **1** |
| 4 | filtro `CODIGOS_DE_RECUSA` do fallback removido | 5 | **5** |

**O que a sabotagem 1 mediu, e é o achado que justifica o bloco de comentário:** sem o
`revoke`, `anon` fica com `DELETE,INSERT,REFERENCES,SELECT,TRIGGER,TRUNCATE,UPDATE` na
tabela — o `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO anon` do próprio baseline
alcança toda tabela criada depois dele. E o caso **comportamental** reprovou com
`erroSob` devolvendo `null`: `anon` LEU sem erro (a RLS devolve zero linha calada). É
por isso que catálogo e comportamento são dois casos e não um — e a mensagem de falha
foi melhorada (`esperaBarrado`) para dizer "a tabela está exposta" em vez de reclamar de
`toContain` sobre `null`.

### Um defeito MEU, pego por um gate que já existia

`pnpm test:unit` reprovou `tests/unit/audit-resource-id-e-uuid.test.ts`: eu tinha escrito
`resourceId: "1"` (a chave do singleton) e `api_audit_log.resource_id` é `uuid`. O INSERT
do audit estouraria com 22P02 e — como audit é fire-and-forget por doutrina — a marca
seria gravada, a tela diria "salvo" e a trilha ficaria **sem a linha, sem sintoma**.
Corrigido para `resourceId: null` (a tabela tem uma linha; `resourceType` já a identifica).

### Achados que NÃO consertei (dívida alheia, registrada em vez de misturada)

1. **As duas listas de ação de auditoria já divergem em 120 códigos.** Medido em
   2026-08-13, com a minha entrada já nas duas: **208** no union de `lib/audit/actions.ts`
   contra **88** em `components/admin/audit/action-codes.ts`; o inverso é **0**. O
   comentário do topo diz *"keep in sync manually"* e **não há gate nenhum**. O conserto
   certo é derivar a lista do union, não copiá-la melhor — item próprio.
2. **Apêndice não-idempotente não é pego por gate nenhum.** A sabotagem 2 provou:
   `test:db` sai **0** e a suíte fica **14/14 verde** com o `create table` duplicando erro
   no `update.sh` de todo clone, porque o modo update tolera erro **por contrato**. Quem
   detecta é o diff de stderr, que hoje ninguém roda. Um gate barato seria comparar o
   conjunto de erros do `update` contra uma lista congelada.

### O que ficou SEM cobertura (declarado, não escondido)

- **Nada foi provado pela tela.** Esta fase não tem UI (a tela de marca é a Fase 2), e o
  DoD 12 só morde quando há UI/fluxo. O que a tela mostraria — `<style id="marca-instalacao">`
  — já foi provado na Fase 1b; o que mudou aqui é **de onde vem** o valor.
- **A server action não tem chamador.** É a Entrada declarada da fase; a tela que a
  aciona é a Fase 2. Enquanto isso, o único caminho para a tabela é a semeadura.
- **`logo_url` e os 8 arquivos que ainda importam `branding()` continuam lendo o `.env`.**
  Medido depois da mudança: `app/layout.tsx` saiu da lista (era 9, é 8) e `generateMetadata`
  (título da aba, herdado por toda página via `template`) é o único consumidor do banco. Medido o motivo:
  `tests/unit/branding.test.ts:71-72` fixa `APP_NAME:\s*env\.APP_NAME` dentro de
  `app/public-env-script.tsx` — e converter o seam do cliente em massa deixaria uma linha
  velha do banco atropelar o `.env` em TODO o produto antes de existir tela para
  corrigir. Fase 2 fecha isso junto com a camada da organização, que precisa do mesmo seam.
- **`lib/database.types.ts` não foi regenerado, e não precisou:** `createAdminClient()`
  devolve `SupabaseClient` **sem** o genérico `Database` (`lib/supabase/admin.ts:24`),
  então `.from("platform_branding")` não passa pelos tipos gerados. Precedente medido: a
  tabela `org_guardrail_layers` (migration 0142) também **não** está lá — `grep -c` = 0.
- **`fallback_at` não tem tela ainda.** É gravado e limpo pelo `app/layout.tsx`; quem lê
  hoje é quem abre o banco. A tela da Fase 2 é a consumidora natural.

---

## Fase 2 — ENTREGUE e PROVADA NA TELA (`0872214d` + `50c20179`)

`public.platform_branding` + tela `/admin/marca`. Ciclo completo medido no browser,
com login MFA real, em build de produção:

| estado | swatches | `--color-accent` |
|---|---|---|
| inicial | 1 | `#506d48` (Sage) |
| digitando `#7a5cd6` | 15 | `#506d48` |
| hex inválido | — | **Salvar desabilitado** |
| salvo | 15 | **`#604aa6`** |
| recarregado | 15 | **`#604aa6`** — persistiu |

`#604aa6` é exatamente o tom que a tela anunciou como "Botões no modo claro".
**A tela não mentiu.** Banco após salvar: `accent_hex=#7a5cd6`, `seeded_from_env=f`.
Zero jargão técnico, zero rolagem lateral.

Cada imagem abaixo é lastro de uma afirmação desta seção:

| evidência | o que ela prova |
|---|---|
| `evidence/marca-1-inicial.png` | estado inicial: sem cor definida, a tira **não** aparece e "De onde vem cada coisa" diz "padrão do sistema" nas três linhas |
| `evidence/marca-2-digitado.png` | ao digitar `#7a5cd6` a tira acende com as três marcações e os contrastes 6,9:1 / 7,8:1 |
| `evidence/marca-3-invalido.png` | hex inválido: borda de erro e **Salvar desabilitado** |
| `evidence/marca-4-salvo.png` | o run que **reprovou**: `Could not find the table 'public.platform_branding' in the schema cache` — o banco local não tinha a migration |
| `evidence/marca-5-recarregado.png` | depois de aplicar a 0155: sidebar e botão roxos, "Cor → definido nesta tela", cor persistida no reload |

### O primeiro run REPROVOU, e o erro apareceu na tela

`Could not find the table 'public.platform_branding' in the schema cache`. Não era bug
do código: o baseline fora provado num Postgres descartável, mas **o Supabase local
nunca recebera a migration**. Aplicada a 0155 no banco local, o ciclo fechou. Sem
dirigir o browser, isto teria virado "está pronto" com a feature morta.

### Dois defeitos de PROSA — nenhum gate pega texto errado

1. A frase do ajuste citava o degrau errado: com `#f5c518` o modo escuro anda +2 e a
   tela dizia *"um tom mais escuro da sua cor"* quando o botão pousa **exatamente na
   cor da pessoa**.
2. Com marca neutra (`#808080`) a tela dizia *"sua cor ficou parecida com sucesso"* —
   sobre uma cor que não pinta nada. A colisão era entre o verde **do produto** e o
   verde de sucesso **do produto**.

### A regra que virou doutrina

`baseline.sql` tem `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO anon`, e ele vale
para **toda tabela criada depois dele** — todo apêndice novo. Sem `revoke`, `anon` fica
com 7 privilégios. É o análogo, **para tabela**, da regra de `security definer` do item 9
do CLAUDE.md. Medido na sabotagem: sem o revoke o **SELECT passa sem erro**, porque a RLS
devolve zero linha calada — por isso o teste tem dois casos, catálogo e comportamento.

### Higiene do ambiente compartilhado

Durante a prova deixei `accent_hex=#7a5cd6` no Supabase local, que mudaria a cor do app
para **qualquer outra sessão** no mesmo banco. Limpo (`null`/`null`). Spec temporária e
`.env.e2e` removidos.

### Dívida declarada

- **Só o título da aba lê o banco.** Os 8 call sites de `branding()` continuam no `.env`
  — a tela **diz isso** embaixo do campo, em vez de deixar o operador achar que quebrou.
- Logo e `show_powered_by` não têm controle: `Sidebar.tsx:46` lê o logo do `.env`, e
  `show_powered_by` tem **zero** consumidores. Campo que salva valor que ninguém mostra
  seria controle decorativo.
- `fallback_at` é inalcançável **pela UI** — três guardas, não uma: o botão Salvar
  desabilitado (`app/admin/(protected)/marca/_form.tsx:313`), o `platformBrandingSchema`
  na Server Action (`lib/schemas/settings.ts:172`) e o CHECK do banco. **Pelo `.env` é
  alcançável**, e esse é o caminho que existe: `lib/env.ts:201` é
  `z.string().optional().default("")`, sem formato — `APP_ACCENT_HEX=verde` desce por
  `camadaDoAmbiente` e acende o alarme com `semente_invalida`. Coberto por
  `tests/unit/branding-fallback-alcancavel.test.ts`.
- **Spec e2e da marca é dívida:** a prova foi por spec temporária, removida porque suja
  o banco compartilhado. Uma spec de verdade precisa de `afterAll` restaurando, e de ser
  declarada em `.github/workflows/e2e.yml` (senão reprova o job **`verify`**, não o `e2e`).

### Defeitos pré-existentes anotados (fora do escopo)

| Defeito | Evidência |
|---|---|
| `border-error-fg/30` **não gera CSS nenhum** no Tailwind 3.4 (opacidade sobre cor em `var()`) — 2 telas do repo estão sem borda | `app/app/contacts/[id]/_client.tsx:64`, `components/contacts/AnonymizeDialog.tsx:102` |
| As duas listas de audit divergem em **120 códigos** (208 no union × 88 no painel) | `lib/audit/actions.ts` × `components/admin/audit/action-codes.ts`, que diz "keep in sync manually" e não tem gate |
| Apêndice não-idempotente **não é pego por gate nenhum** — `test:db` fica verde | o modo update tolera erro por contrato; quem detectaria é um diff de stderr que nenhum job roda |
| **E-mail pessoal do dono no User-Agent** de toda instalação self-host | `lib/nuvemshop/config.ts:13` → sai em `api-client.ts:68`. **Decisão do Rafael**: qual endereço de projeto usar |

---

## Fase 3 — ENTREGUE e PROVADA NA TELA

Commits: `0513755c` (RPC atômica + invariante) · `032ba43a` (camada na pilha) ·
`a8ec1d99` (CSS escopado + layout) · `55b6f6ea` (a tela).

### O que a tela mediu, com admin de tenant PURO

**A precondição falhou primeiro, e era a armadilha prevista:** `e2e-admin@deskcomm.test`
**era** `platform_admin` (`seed-e2e-system-update.ts:52-68` promove e nada revoga). Medi
`count = 1`, revoguei, reafirmei `count = 0`, e só então testei. Sem isso o teste teria
passado verde medindo o produto errado.

| | `--color-accent` | `[data-marca-org]` | `#marca-organizacao` |
|---|---|---|---|
| `/app`, org sem marca | `#506d48` Sage | presente | **ausente** |
| `/app`, depois de salvar `#b3261e` | **`#b3261e`** | presente | **presente** |
| `/app`, tema escuro | **`#f16051`** | presente | presente |
| `/login`, sem sessão | `#506d48` | **ausente** | **ausente** |

`persistido = #b3261e` depois do reload — **a issue #144 não se repetiu**: o RPC gravou
de verdade sob um admin que não é platform admin. O bloco de estilo **só existe quando a
org tem marca própria**, e no escuro o valor é outro (o claro não sobrevive por herança).

Evidência: `evidence/org-1-tela.png` (a tela), `evidence/org-2-digitado.png` (a tira
reagindo), `evidence/org-3-salvo.png` (o salvamento), `evidence/org-4-recarregado.png`
(a persistência), `evidence/org-5-login.png` (o caso de volta, sem sessão).

### O ambiente compartilhado saturou no meio, e isso é registro honesto

Depois das primeiras medições o login parou de completar. **Não era o produto:**
`auth.audit_log_entries` acusou **93 tentativas em 10 minutos** (teto: 60/IP/300s) e o
**fator TOTP mudou de ID** — outra sessão re-semeou e invalidou o `.e2e-creds.json`.

Por isso o caso de volta foi medido **sem sessão**: `/login` é rota pública, e a
comparação usa o `#b3261e` já medido em `/app`. Mesma propriedade, mesmo rigor, sem
depender de um login que o ambiente não estava entregando.

### Descoberta lateral — ~~correta~~ **REFUTADA por medição** (Onda 1, 2026-08-14)

> ~~**Revogar o `platform_admin` removeu a exigência de MFA** do usuário — o `mfa_required`
> vinha de lá. O helper de login do repo trava esperando a tela de TOTP que deixa de
> aparecer, e o sintoma lê como "MFA quebrou".~~

**Isto é falso, e agir sobre ele levaria a mexer no gate de MFA sem defeito nenhum.**

1. `lib/auth/server.ts:160` — `requiresMfa(role, isPlatformAdmin)` é
   `isPlatformAdmin || role === "admin"`. `e2e-admin@deskcomm.test` tem role de
   **tenant** `admin`, então a exigência continua de pé com ou sem a promoção.
   `platform_admins.mfa_required` é uma coluna da tabela; não é ela que o produto lê
   nesse caminho.
2. A causa medida é **rotação do fator TOTP por outra sessão**, que este mesmo
   documento já registrava duas seções acima ("o fator TOTP mudou de ID"). Medido de
   novo hoje: `.e2e-creds.json` trazia `factor_id 49c64c17…` e o banco tinha
   `81ee0438…` — o `seed-e2e-credentials.ts` rotacionou. Com o secret velho, o código
   é recusado e o login fica preso em `/login/mfa`.

Antes de tocar em código por causa deste sintoma, **meça**:

```sql
select count(*) from auth.mfa_factors
 where user_id = (select id from auth.users where email = 'e2e-admin@deskcomm.test')
   and status = 'verified';
```

### Estado deixado no banco local (compartilhado)

- `e2e-admin@deskcomm.test` segue **revogado** de `platform_admins`. É o estado correto do
  seed base. ⚠️ **Atualizado na Onda 1:** a frase "quem precisa dele promovido é
  `seed-e2e-system-update.ts`, que repromove ao rodar" **deixou de valer** — esse seed
  agora promove o `e2e-dono@deskcomm.test` e **revoga** o `e2e-admin` a cada execução.
  Rodá-lo é o conserto de um banco contaminado, não a recontaminação.
- A marca da org de teste (`E2E Test Org`) ficou gravada com `#b3261e`.

### Sem cobertura, declarado

- **A spec e2e permanente não foi escrita** (commit 6 do plano): a prova foi por spec
  temporária, removida porque suja banco compartilhado. Uma spec de verdade precisa de
  seed próprio (`e2e-marca-admin`, sem `platform_admins`), `afterAll` restaurando, e
  declaração em `.github/workflows/e2e.yml` — senão reprova o job **`verify`**, não o `e2e`.
- **A captura no onboarding** (commit 5) não foi feita.
- O caso do **portal Radix** (menu aberto herdando a cor da org) não foi medido em pixel —
  é afirmação por construção, com o teste de fonte guardando o seletor.

---

## Fase 4 — ENTREGUE (`a6acb9f1`): a marca alcança quem não tem DOM

O seam é `lib/branding/saida.ts`: `marcaDaSaida(orgId | null)` devolve **um hex e uma frente
legível**, tema **claro sempre** (e-mail não tem tema, e `prefers-color-scheme` dentro de
e-mail não é confiável). O piso vem **importado** de `regua-do-produto.ts`, nunca constante
nova — seria a quarta cópia de um hex do produto. Contrato: **nunca lança**, porque quem chama
é o e-mail de LGPD, que responde a direito legal com SLA de D+7.

**O PDF de LGPD não leva marca nenhuma.** Imprime o **controlador** (`legal_name`) e o DPO
resolvido. Nomear ali o revendedor — que é *operador* — inverteria papéis num documento
jurídico. Consequência boa: o `StyleSheet` module-level do `@react-pdf` fica como está, e a
armadilha de ele aceitar `var(--x)`/`oklch()` e descartar a cor **em silêncio** deixa de
existir para nós.

**O remetente é do operador; o nome de exibição é da marca.** `RESEND_FROM_EMAIL` vazio passa a
significar `not_configured`, não o nosso domínio — o fallback antigo fazia *todo* envio falhar
na Resend do revendedor com mensagem opaca. Agora cai no caminho que já existia e já é bom.

8 das 9 dívidas da guarda resolvidas; a 9ª (`ai-budget-alarm`) foi **reclassificada** com
motivo medido, e um caso novo trava o conjunto **pelo nome**, não pelo tamanho.
Sabotagem: **1/1, 3/3, 1/1**. Gates: typecheck 0 · lint 0 · lint:channels 0 · test:unit
386 files / 4399 · test:shell 253 ✓ / 0 ✗ · build 0.

## Fase 5 — ENTREGUE (`71616d78` + `741c4ec8`): a aba, a barra e o primeiro e-mail

**Favicon.** `/favicon.ico` devolvia 404 (19.435 bytes, a `not-found.tsx` inteira) — para nós
e para todo revendedor. `app/icon.tsx` gera em runtime com `next/og`, zero dependência nova,
`force-dynamic` (sem isso o build congela o ícone dentro da imagem, defeito invisível em dev,
em teste e na Vercel, e visível só na VPS). `/^\/icon$/` entrou em `PUBLIC_PATHS`, senão o
ícone respondia **307 para `/login`** — justamente para quem ainda não entrou.

**A barra do navegador não era defeito de whitelabel**, ao contrário do que a medição anterior
dizia: `cssDaMarca` emite só `--color-brand` e a família do accent, **nunca `--color-bg`**. O
defeito real era **duplicação** dos dois hexes em três arquivos. Provado por comportamento:
com `accent_hex='#f2c94c'`, o ícone virou **V sobre `#6e5c28`** (o accent derivado) e o
`theme-color` **não** mudou.

**E-mails de acesso.** `hostgator-setup-kit/marca-emails.sh` sobe assunto e corpo pela
Management API. A medição que destravou a decisão: o `PATCH /config/auth` com
`mailer_templates_*` **é aceito e persiste sem SMTP customizado**. Achado do rig: **projeto
pausado responde 400 "Project is paused."** — modo de falha que um script confiando em 2xx
reportaria como sucesso, e por isso o script **relê** o que gravou.

Sabotagem: **oito, zero divergência**. Gates: typecheck 0 · lint 0 · lint:channels 0 ·
test:unit **389 files / 4426** · test:shell **265 ✓ / 0 ✗** · build 0 com `/icon` dinâmico.

## Fase 6 — ENTREGUE (esta sessão): a documentação para de mentir

- **`docs/white-label.md` reescrito.** As duas frases mais citadas dele eram falsas depois
  deste épico ("cores, fontes e tema não são configuráveis" e "a marca é por instalação, não
  por organização"). Entraram: as duas telas, o banco acima do `.env`, os e-mails (acesso,
  convite, LGPD, suporte), e uma seção própria explicando por que **o PDF de LGPD não leva a
  marca do revendedor**. O que **continua** não sendo configurável agora vem com a razão
  medida: domínio por organização (zero coluna no schema, o desvio por host do `proxy.ts:27` é
  NOOP declarado, e no Edge não há banco), fonte (`next/font` resolve em build e a imagem é
  pré-buildada), tema e logo por organização.
- **Mapa vivo** `docs/architecture/marca-propria.architecture.json` — 37 peças, 54 arestas, 6
  faixas, com o **PDF declarado FORA da marca** e um caso no gate que reprova quem o ligar ao
  resolvedor. Duas linhas entraram no `docs/architecture/README.md` (a da marca e a do
  `indice-de-atrito`, que faltava). As **três** instruções "re-renderize com archify" —
  `sistema-vivo.md` ×2 e a skill — foram corrigidas: o archify 2.11.0 recusa o formato.
- **Jornada nova** no `docs/testing/user-journey-map.md`, com a persona que faltava (o
  revendedor) e 8 casos. O TOTP **deixou de ter duas biografias**.
- **Catraca nova** `tests/unit/audit-listas-nao-divergem-mais.test.ts` — nasce verde,
  congela as 120 divergências e reprova acréscimo.
- **Números de CI corrigidos** em quatro arquivos (ver abaixo).

## Living System Checklist — marca própria (o épico inteiro)

Régua: `docs/doctrine/sistema-vivo.md`. Resposta que não nomeia artefato concreto não vale.

| # | Pergunta | Resposta, com o artefato |
|---|---|---|
| 1 | **Quem me alimenta?** | `hostgator-setup-kit/install.sh:1365-1379` grava a marca no `.env` → `sementeDoAmbiente()` (`lib/branding/instalacao.ts`) semeia `public.platform_branding` na primeira leitura. E a mão humana, por duas telas: `app/admin/(protected)/marca/_form.tsx` (instalação) e `app/app/settings/marca/_form.tsx` (organização) |
| 2 | **Quem eu alimento?** | `app/layout.tsx` (`<EstiloDaMarca/>` + `generateMetadata`), `app/app/layout.tsx` (`<EstiloDaMarcaDaOrganizacao/>`), `app/icon.tsx`, e via `marcaDaSaida()`: `lib/email/templates/invite.ts`, `lib/lgpd/email-delivery.ts`, `lib/lgpd/sla-alarm.ts`, `app/actions/auth/enrollMfa.ts` (`issuer`), `lib/email/resend.ts` (nome do remetente). Fora do processo: `hostgator-setup-kit/marca-emails.sh` → GoTrue |
| 3 | **Que atividade/log eu emito?** | `audit("platform_branding.updated")` em `app/actions/settings/updateBranding.ts:123` e `audit("org.branding_updated")` em `app/actions/settings/updateMarcaDaOrganizacao.ts:155` — as duas em `api_audit_log`, e **as duas presentes nas duas listas de código** (`lib/audit/actions.ts` e `components/admin/audit/action-codes.ts`). Mais `registrarEstadoDaMarca()`, que grava `fallback_at`/`fallback_reason` na própria tabela, e o `logger.warn` estruturado quando a cor é recusada. **Não há `event_log` de propósito:** nenhum handler consumiria o tipo (anti-pattern nº 3 — evento sem consumidor) |
| 4 | **Onde apareço na tela?** | `/admin/marca` (valor gravado + `TiraDeTons` com os tons derivados + o estado do degrade em português), `/app/settings/marca`, `/admin/audit` (as duas ações), a aba do navegador (título + ícone), a barra lateral e o corpo de cada e-mail |
| 5 | **Por qual porta se chega?** | `lib/navigation/registry.ts:453` — `/app/settings/marca`, grupo Configurações, `sidebar:false` (trocar a marca é tarefa de uma vez; o hub e o ⌘K garantem a descoberta). Vigiado por `tests/unit/navegacao-completude.test.ts`. `/admin/marca` é de platform admin e fica fora dessa varredura por construção |
| 6 | **Mecanismo anti-morte?** | Três, e nenhum é promessa: (a) cor recusada **não apaga a camada de baixo** — `resolverMarca()` continua descendo até o padrão do produto; (b) `marcaDaInstalacao()` e `marcaDaSaida()` **nunca lançam** (um throw em `app/layout.tsx` é 500 em todas as telas; um throw no e-mail de LGPD é descumprimento de prazo legal); (c) tabela ausente (`42P01`) degrada para o `.env` com aviso, que é o estado de um clone que ainda não aplicou a migration |
| 7 | **Onde se CONFIGURA?** | **Ver:** `/admin/marca` mostra o valor em vigor, de onde ele veio (banco, `.env` ou padrão) e **por que** foi recusado, se foi. **Mudar:** o formulário da mesma tela — e `/app/settings/marca` para a organização. **Faltando:** cai no padrão do produto com `fallback_reason` escrito, nunca num `return` mudo |
| 8 | **Continuidade IA↔humano?** | **N/A justificado.** A marca não participa de handoff: é configuração de apresentação, não demanda de atendimento. Nenhum turno de agente lê ou escreve marca |
| 9 | **Qual meu LAÇO DE RETORNO?** (invariante 7) | Quando a cor do operador **não** pinta, o sistema não fica calado: `app/layout.tsx` chama `registrarEstadoDaMarca(motivo)`, que grava `fallback_at` + `fallback_reason` **de volta** em `platform_branding`; `/admin/marca` lê e mostra; e o log estruturado nomeia o token e a forma recusada. É o que distingue "o produto ficou com a cor dele" de "a feature nunca foi instalada" — e é a única razão de isso ser **coluna**, não `logger.warn` |
| 10 | **Atualizei o mapa vivo?** | `docs/architecture/marca-propria.architecture.json` (37 peças / 54 arestas), linha no `docs/architecture/README.md`, e caso concreto em `tests/unit/mapas-de-arquitetura.test.ts` cobrando **≥2 arestas** de 8 peças nomeadas. **Sem re-render:** o archify 2.11.0 recusa o formato `architecture` — a instrução contrária foi corrigida em três lugares |

## Números de CI corrigidos (2026-08-14 @ `741c4ec8`)

| Onde | Dizia | Medido |
|---|---|---|
| `CLAUDE.md` (2 pontos) | "37 das 39 specs"; "Todos os **quatro**"; `imagens-ok` "ainda não é obrigatório" | **45 das 46**; **cinco**; `imagens-ok` **é** obrigatório |
| `AGENTS.md` (4 pontos) | "os três são checks"; "28 das 32"; "não é obrigatório ainda"; 221 unitários / 67 invariantes / 32 specs | **cinco**; **45 das 46**; **obrigatório desde 2026-08-08**; **257** / **102** / **46** |
| `docs/harness-audit.md` | "4 das 32 specs fora do CI, não-obrigatório" | **1 das 46**, obrigatório |
| `docs/current-state.md` (3 pontos) | "28 das 32"; "e2e não é obrigatório"; "`imagens-ok` ainda não está na branch protection" | **45 das 46**; obrigatório; `imagens-ok` entrou |

Comandos: `ls tests/e2e/*.spec.ts | wc -l` → 46 · parse das listas do `e2e.yml` → 45 rodam, 1
em `FORA_DO_CI` (`vps-fresh-onboarding`), soma == disco · `gh api …/branches/main/protection
--jq '.required_status_checks.contexts|join(", ")'` → `verify, build-and-size, invariants,
e2e, imagens-ok`.

## Dívida declarada — depois do épico

| # | Dívida | Condição para sair |
|---|---|---|
| D1 | Marca no **alarme de orçamento de IA** (`fase: 7` na guarda) | **Condição reescrita em 2026-08-15** — a anterior apontava para `runBudgetChecker()`, função que deixou de existir: `workers/ai-budget-checker.cron.ts` foi apagado no épico do teto de orçamento (0159/0160) por nunca ter tido agendador. Depois disso `lib/email/templates/ai-budget-alarm.tsx` ficou **sem chamador nenhum**. Sai quando o alarme ganhar cron de verdade (rota em `app/api/v1/cron/` + linha no `docker/scheduler/entrypoint.sh` + um chamador) **ou** quando o template for apagado junto. Redação canônica em `tests/unit/branding.test.ts` (entrada `lib/email/templates/ai-budget-alarm.tsx`) e em `docs/architecture/marca-propria.architecture.json` — os três descrevem a MESMA dívida e agora dizem a mesma coisa. Hoje o efeito de consertar continua sendo **zero observável** |
| D2 | `white-label.md` em **EN/ES** | Um gate que reprove tradução defasada. Sem ele, três cópias divergem no primeiro conserto seguinte, e guia comercial errado em inglês é pior que ausente |
| D3 | **Upload de logo** (saiu do épico) e **fonte/tema** por tenant | Logo: bucket + policies + teto de 512 KB + delete-on-replace, com a cota do Supabase do cliente medida. Fonte/tema: exigiria `Font.register` e o arquivo dentro da imagem — o `next/font` resolve em build |
| ~~D4~~ | ~~As 120 divergências entre `lib/audit/actions.ts` e o painel~~ | **RESOLVIDA** em `33ce8612`: a lista do painel passou a **derivar** do union e `action-codes.ts` foi apagado. Divergir deixou de ser possível, então a catraca que congelava os 120 foi apagada junto — gate que guarda classe inexistente é ruído. O filtro do painel foi de 89 para 209 códigos |
| ~~D5~~ | ~~`docs/design-system/screen-flow/03-screen-inventory.md` — seção M diz "15 telas" e tem **17** linhas~~ | **RESOLVIDA** em 2026-08-14, `214f47f0`. A passada no inventário inteiro achou mais do que a dívida dizia: **9 dos 10 totais do doc estavam errados**, não um (C dizia 3/tinha 2; G dizia 10/tinha 11; M dizia 15/tinha 17; abertura "~70", Resumo "~74", P0 "~32", P1 "~30", P2 "~12", realtime "~22" — contra 94 linhas, 41, 46, 6 e 27). `/admin/marca` virou a linha #90 e o total foi a 95. O doc ganhou a seção "Reconciliação com o disco" (42 rotas planejadas e não construídas, 42 páginas construídas fora do plano, 3 que existem sem `page.tsx`), o instrumento `scripts/inventario-de-telas.ts` e o gate `tests/unit/inventario-de-telas.test.ts` |
| D6 | `marca-emails.sh` não alcança quem instalou colando as 4 credenciais **sem** `SUPABASE_ACCESS_TOKEN` | Uma forma de o operador autorizar a Management API depois da instalação, sem guardar chave mestra no `.env` do cliente |
| ~~D7~~ | ~~`fallback_at` é **inalcançável pela UI** — o CHECK do banco barra o hex corrompido antes~~ ~~Só aparece para quem edita o banco à mão ou vem de clone com valor legado~~ | **NÃO ERA DÍVIDA, E A RAZÃO ESCRITA ESTAVA ERRADA** (medido 2026-08-14, `214f47f0`). As duas saídas que a frase enumerava não existem: *editar o banco à mão* é barrado pelo próprio CHECK que ela cita (a constraint entrou na `create table` da 0155, não depois), e *clone com valor legado* é impossível pelo mesmo motivo — a coluna nunca existiu sem ela. O caminho que **funciona** ficou de fora: o `.env`. `lib/env.ts:201` é `z.string().optional().default("")`, sem formato; `APP_ACCENT_HEX=verde` acende `semente_invalida`. Agora é caso de teste (`tests/unit/branding-fallback-alcancavel.test.ts`, 5 casos), não dívida |
| D8 | Prova de **instalação fresca ponta a ponta com marca de revendedor** | Mesma lacuna de `vps-fresh-onboarding`: nenhum job prova a jornada de quem compra. É onde eu apostaria o próximo defeito de marca |

## Próximo passo exato

O épico está fechado no código e na documentação. Falta o que só o dono faz: **PR e merge na
`main`** — autorizado explicitamente por Rafael em 2026-08-13. Ao abrir o PR, o `e2e` roda pela
primeira vez a `tests/e2e/icone-da-marca.spec.ts`; se ela reprovar por ambiente, o lugar dela é
`FORA_DO_CI` **com o motivo medido**, nunca uma exclusão preventiva.
---

## Alocação de migrations da continuação (medida 2026-08-14 @ `11d87a11`)

Três blocos de trabalho reivindicavam **0158** ao mesmo tempo. Colisão de número de
migration **não** se resolve "cada um re-mede na hora": dois construtores medem o mesmo
minuto e acham o mesmo número livre. A alocação é central e fica aqui.

| Número | Dono | Onda |
|---|---|---|
| **0158** | `logo_no_storage` — bucket + `logo_path` + RPC | 6 |
| **0159** | `selo_dos_emails_de_acesso` | 8 |

Medido em **todas** as refs locais e remotas (`git branch -a` × `git ls-tree`): o maior
ocupado é **0157**. Reconte antes de usar.

**Uma migration foi CORTADA:** a que existiria só para trocar um `comment on table`.
Preço desproporcional — arquivo + apêndice + MANIFEST + `test:db` obrigatório (~6 min de
Docker) + consumir um número disputado, em troca de uma string em `pg_description` que
ninguém lê em campo. **O comentário do banco fica desalinhado de propósito.** A mesma
frase falsa está num arquivo que humanos leem — `hostgator-setup-kit/marca-emails.sh:105-109`,
que o próximo mantenedor do kit lê antes de mexer em `ACCENT` —, e corrigir *essa* custa
uma linha. Se alguém quiser alinhar o comentário do banco, que vá de carona numa migration
que exista por outro motivo.

### E a ordem no apêndice do `baseline.sql` — a premissa em circulação era falsa

"O bloco da varredura anon é o último do arquivo" é **falso**: medido, **quatro blocos
vêm depois dela**. O que o guarda (`tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts`)
proíbe depois da linha da varredura são exatamente duas coisas: `create function` e
`grant … to … anon`.

Consequência prática: a **0158 cria duas funções**, então é obrigatoriamente **antes** da
varredura — e o plano original mandava colá-la "no fim do arquivo", o que teria deixado
`pnpm test:unit` vermelho num teste que ele nunca citou.

---

## Incidente de infraestrutura (2026-08-14) — e o que ele bloqueia

O disco encheu no meio da execução (**166 MB livres, 100%**), com o `.env.local`
temporariamente movido para `/tmp` por um gate. O arquivo foi recuperado íntegro
(4361 bytes). Limpei **só o que é meu** — 7,0 GB e 1,3 GB em `/tmp/claude-501` são
de **outras sessões** e não foram tocados (posse se mede, não se infere pela idade).

Sequela: o **daemon do Docker travou** (`docker ps` pendura sem retorno) e o Postgres
local caiu. Isso bloqueia, enquanto durar:

- **prova em tela** de qualquer onda (precisa do Supabase local),
- **`pnpm test:db`** (sobe `pgvector/pgvector:pg17` descartável),
- **`pnpm test:e2e`**.

**O que NÃO está bloqueado:** `typecheck`, `lint`, `lint:channels`, `test:unit`,
`build` e `test:shell` — que é onde as ondas seguem correndo.

Registro para não virar afirmação otimista: **as ondas cuja prova em tela ficou
pendente estão marcadas como tal**, e prova pendente por infra **não é** prova feita.

---

## Balanço da continuação (2026-08-14)

| Onda | Entrega | Estado |
|---|---|---|
| 0 | Alocação de migrations + números podres | ✅ `f424cf9b` |
| 1 | Identidade do e2e (usuário dedicado, precondição, gate) | ✅ `1860a747` + `66924aad` |
| 2 | O logo do banco chega à tela | ✅ `7934e0d4` |
| 3 | `install.sh` pergunta e grava a cor | ✅ `25910ac6` |
| 5 | A lista de audit do painel **deriva** do union | ✅ `33ce8612` + `a685f721` |
| 6 | Upload de logo | ✅ `fea8483d` — **`test:db` pendente** |
| 9 | Inventário de telas + `fallback_at` alcançável | ✅ `c8fc877d` |
| 10 | `white-label` em EN/ES com selo | ✅ `590ed059` |
| **4** | **Specs e2e da marca** | ⛔ **não feita** — ver abaixo |
| **7** | **Alarme de orçamento de IA** | ⛔ **não feita** — ver abaixo |
| **8** | **Selo dos e-mails de acesso** (migration 0159) | ⛔ **não feita** — ver abaixo |

### Por que as três não foram feitas — e não é falta de tempo

**O daemon do Docker está fora do ar** desde que o disco encheu (`docker info`
pendurou >10 min sem devolver byte). Isso derruba `test:db`, `e2e` e prova em tela.

- **Onda 4 (specs e2e):** escrever spec que **nunca roda** é produzir `expect()` que
  não executa — é exatamente o que o plano cortou no caso de `vps-fresh-onboarding`.
  Escrevê-las agora só para "entregar" seria encenação.
- **Onda 7 (alarme de orçamento):** é o **RISCO MAIOR** do plano inteiro, e por um
  motivo só: ela **liga** algo hoje morto cujo efeito é **negar serviço**. Numa
  instalação em que alguém preencheu `monthly_limit_cents` há meses — com o contador
  travado em 0 — o primeiro tick **estrangula a IA da organização**, e o cliente
  descobre por um agente que parou de responder no WhatsApp. Construir isso **sem
  poder provar no banco** seria imprudência, não velocidade.
- **Onda 8 (selo dos e-mails):** traz a migration **0159**, e migration sem `test:db`
  não se merjeia — é o gate que exercita o `baseline.sql` que o self-hoster aplica.

### O que fica pendente de prova, nominalmente

- `tests/invariants/marca-logo.test.ts` — **escrito e nunca executado**.
- `install`/`update` do baseline **com a 0158** — não provados.
- Prova em tela das ondas 2 e 6 (logo na sidebar, no `/login` deslogado, e a troca
  entre camadas) — roteiro pronto no relatório da onda 6.

**Nada disso é afirmado como feito em commit nenhum.** Prova pendente por infra não
é prova feita.

---

## Próximo passo exato

Com o Docker de volta: `pnpm test:db`, a prova em tela das ondas 2 e 6, e então as
ondas 4, 7 e 8 — nessa ordem, a 7 com checkpoint próprio pelo risco declarado.

---

## Merge da `main` (2026-08-14) — `587a494d`

A `main` andou 39 commits enquanto esta branch existia, e uma parte deles é
**marca própria mergeada por outra sessão**. Convergência independente é o caso
em que o merge mais engana: os dois lados fizeram a mesma coisa e o git não
acusa conflito nenhum.

### Três conflitos, e nenhum aceitava escolha de lado

| Arquivo | `ours` perderia | `theirs` perderia | Resolução |
|---|---|---|---|
| `lib/audit/actions.ts` | os 8 códigos novos da main | a derivação da onda 5 | **combinado**: 209 + 8 = 217 |
| `components/admin/audit/action-codes.ts` | — | a deleção | **deleção mantida**, depois de conferir os 8 |
| `.github/workflows/e2e.yml` | 2 specs da main | `marca-logo.spec.ts` | **combinado**, e rebalanceado |

A ordem importou: **conferi que os 8 códigos da main já estavam no union ANTES
de manter a deleção**. Na ordem inversa eu teria apagado o arquivo e descoberto
a perda quando o painel ficasse 8 códigos atrás — que é exatamente o defeito
que a onda 5 existiu para matar, reintroduzido pelo conserto dele.

### O quarto, que não deu conflito

`app/api/v1/marca/logo/route.ts` chamava `mfaEmDivida(org.role, is_platform_admin)`.
A main mudou a função para **não receber mais papel**: com a verificação em duas
etapas agora opcional, consultar a política antes de olhar a sessão faria o fator
**voluntário** ser ignorado — quem ativasse por vontade própria teria o mesmo
efeito de não ter ativado. A nova versão é mais estrita: quem TEM fator prova,
sempre.

Os dois lados mexeram em arquivos diferentes, então **não houve marcador**. Quem
pegou foi o `typecheck` (TS2554 nas linhas 140 e 169). A irmã
(`updateMarcaDaOrganizacao.ts`) já tinha sido corrigida pela sessão que mergeou
na main; a minha ficou para trás porque **nasceu depois**, na onda 6. Vale
registrar a assimetria: a classe foi tratada, e a instância nova escapou por ser
nova — não por ser diferente.

### Rebalanceamento do `e2e.yml`, e o número que estava errado

A divisão em `SPECS_PARTE_1`/`PARTE_2` existe por causa do teto de 60 logins por
IP a cada 300s, compartilhado pela suíte inteira. Medido antes de escolher:
**175 vs 132** chamadas de login. Como a minha spec tem 13 e a parte 1 era a mais
carregada, ela foi para a **parte 2**: ficou **162 vs 145**.

⚠️ O número é **proxy** — regex sobre o texto das specs, não login de runtime.
Serve para equilibrar; não promete que não estoura.

O `CLAUDE.md` dizia **45 de 46**. Agora são **48 de 49** — e a causa do
apodrecimento estava na própria receita que ele mandava usar:

    grep -oE '[a-z0-9-]+\.spec\.ts' .github/workflows/e2e.yml | sort -u | wc -l

devolve **49**, não 48, porque varre o arquivo inteiro e conta **menções em
comentário** — inclusive a lista das que ficam de fora, documentada logo abaixo.
Contava quem é **citado**, não quem é **invocado**, e errava **para cima**: quem
seguisse a instrução publicaria um número inflado achando ter reconferido.
Trocada por uma que lê só as `SPECS_PARTE_*`, com controle de sensibilidade
provado (48 → 49 → 48 ao inserir e remover uma spec fantasma numa **cópia**).

### Gates neste SHA

`typecheck` 0 · `lint` 0 erros · `lint:channels` 0 · `test:shell` 0 ·
`test:unit` **5 falhas**, todas em `lib/ai/dispatcher/rate-limit.test.ts`.

As 5 são **do disco local, não do merge**, e a medição é esta — uma variável só:

| ambiente | resultado |
|---|---|
| com `.env.local` | 5 failed (timeout 15s cada) |
| sem `.env.local` | 5 passed |

`.env.local` é gitignored: **o CI não o tem**. Nenhum commit desta branch toca
esse teste. É defeito de DX **herdado**: a suíte prova o fallback em memória
*para quando o Redis está INALCANÇÁVEL*, e quem tem `UPSTASH_*` de verdade no
`.env.local` faz o código tentar a rede e estourar. Ou seja, o teste só passa
para quem **não** configurou Redis — em metade dos ambientes ele mede o oposto
do que o nome diz. **Registrado, não consertado**: não é escopo desta branch.

### Precondição da `marca-logo.spec.ts` — conferida, e eu estava errado

Achei que a spec presumia o dono já promovido a `platform_admins` e que passaria
**de carona** no estado deixado por `system-update.spec.ts`. Isso quebraria no
CI, ainda mais depois de eu ter movido a minha para a outra parte.

Fui conferir: **ela já chama** `seed-e2e-system-update.ts` dentro de
`loadCreds()`. Meu grep procurou `beforeAll|precondicao|garantir` e a spec faz
isso no topo do módulo — **o ponto cego era do instrumento, não da spec**. A
precondição está garantida e é independente da ordem entre as partes.

Fica o registro porque a conclusão errada era a acionável: eu teria "consertado"
uma spec correta.

---

## O CI como banco de provas (2026-08-14, PR #252)

O Docker desta máquina não sobe — o disco da VM corrompeu (`EXT4-fs error:
Detected aborted journal`, `I/O error on dev vda1`), sequela de um disco cheio
mais cedo. Consertar exige reset que apaga contêineres e volumes de outras
sessões, então não foi feito.

Isso parecia bloquear as quatro provas pendentes. **Não bloqueia:** os jobs
`invariants` (que roda `pnpm test:db`) e `e2e` rodam no CI, com Docker do
runner. O PR virou o ambiente de prova — e a `main` fica protegida porque os
dois são checks **obrigatórios** na branch protection.

### O que a primeira execução real revelou

**`tests/invariants/marca-logo.test.ts`: 21 de 22 passaram.** O que falhou foi
a **guarda de vacuidade** — a que existe para provar que os outros 21 não passam
à toa. Ela comparava `p.oid::regprocedure::text` com string literal; o cast de
saída omite o `public.` quando `public` está no `search_path`, então a contagem
dava 0 num banco onde a função existe.

O sentido do erro é a lição: guarda de vacuidade falhando **lê-se como "o schema
não chegou ao banco"**, e o passo natural é mexer na migration — que estava
certa. Quem desmentiu foram os 21 casos do próprio arquivo que CHAMAM as
funções e passaram. A explicação chata ("a função não está lá") tinha que ser
descartada por medição antes de eu aceitar a interessante ("o teste está errado").

### O que a revisão cética achou antes de o CI chegar lá

Um revisor mediu a `marca-logo.spec.ts` na fonte do Playwright 1.62.1 instalado
e achou defeitos **confirmados**, três deles fatais:

| # | Defeito | Por que passa despercebido |
|---|---|---|
| 1 | testes (4) e (5) **nunca fazem login** | `page` é fixture de escopo de TESTE — cada `test` tem contexto novo. O comentário do arquivo afirmava "um login por papel no arquivo inteiro" |
| 2 | restauração é um `test`, em modo serial | o comentário dizia que `afterAll` "não roda quando a spec estoura". **Medido: roda** — quem não roda são os testes seguintes. Foi escolhido o mecanismo pior para o modo de falha que o próprio comentário nomeia |
| 3 | `getByText(/SVG não é aceito/i)` | casa o texto de AJUDA estático da tela, que está sempre visível. Passa em t=0 sem o toast existir |
| 4 | `altura > 0` "prova o download" | `h-7` fixa 28px por CSS: imagem quebrada mede igual. A asserção passa no cenário exato que alega cobrir |

Os três primeiros quebrariam o CI; o 4 é pior num aspecto — faz a spec **afirmar
ter provado** o download do bucket, que é a razão declarada de ela existir em
vez de um `curl`.

### E o meu número, que estava errado nos dois sentidos

Rebalancei o `e2e.yml` medindo "carga de login por parte" com um regex que
contava a **palavra** login — comentário, nome de helper, string. Real: a spec
faz **3** logins, não 13. E por parte: **63 vs 82**, não 162 vs 145 — o proxy
**inverteu o sinal**, e me fez mover a spec para a parte mais carregada
acreditando fazer o contrário.

Pior: o critério era irrelevante desde o começo. O próprio workflow define
`AUTH_RATE_LIMIT_LOGIN_IP: "1000"`, que desliga o teto no CI. Eu otimizei uma
restrição que não existe, com um instrumento que apontava para o lado errado.

O que decide a posição é **contaminação**: as partes são passos do mesmo job,
mesmo banco, sem reset. A spec agora é a **última** da PARTE_2 — se a
restauração falhar, o resíduo alcança zero specs em vez de 23.

⚠️ E o comentário que explica isso mora **fora** do bloco `>-`: dentro dele `#`
não é comentário, é conteúdo. Medido antes de commitar — a variável ia de 23
para **205 tokens**, e cada palavra viraria argumento do `playwright test`.

### Onda 7: o risco herdado estava errado nos três termos

Está em `docs/design/onda-7-alarme-de-orcamento.md`. Resumo: o contador não está
travado (gatilho vivo em `llm_calls` desde a 0095), ninguém precisa "preencher"
o limite (`DEFAULT 5000 NOT NULL`, toda org tem), e não estrangula nada (os três
leitores de `is_throttled` estão mortos). O primeiro tick escreveria **"Pausado"**
na tela de quase toda org enquanto o agente atende normal.

A descoberta que decide o desenho não estava no plano: `runBudgetReset()`
também estava morto e era o **único escritor** de `is_throttled: false`. Ligar o
checker sem ele criaria estado permanente que nem o `update.sh` desfaz. O risco
era real — por outro caminho, e pior.

**Desfecho (2026-08-15):** os dois crons foram APAGADOS
(`workers/ai-budget-checker.cron.ts`, `workers/ai-budget-reset.cron.ts`) no épico
do teto de orçamento, e `is_throttled` foi saneado pela migration 0159. As duas
funções não existem mais; o parágrafo acima é história do diagnóstico, não estado
atual do disco.

**Achado ativo hoje, fora do escopo:** `assertBudget` (o enforcement vivo) lê
`settings.llm.monthly_budget_cents`, que **não tem UI**; a tela mostra
`ai_budgets.monthly_limit_cents`, que ninguém aplica. A tela de orçamento do
tenant é decorativa — o usuário mexe e o código ignora.

### Duas pendências fecharam com prova (2º ciclo do CI, `e3f8d12a`)

| Pendência | Antes | Agora |
|---|---|---|
| `tests/invariants/marca-logo.test.ts` | escrito, **nunca executado** | **executado e verde**, 22 casos |
| baseline `install`/`update` com a **0158** | não provado | **provado** — o job aplica com `ON_ERROR_STOP=1` e reaplica |

`Test Files 104 passed (104)`, e os três arquivos de marca aparecem na lista
executada. Confirmei o arquivo por nome, não só o total do job: "o job passou"
não prova que o meu teste rodou.

O ciclo anterior tinha dado o defeito e este confirma o conserto — o CI serviu
como o banco de provas que a máquina não podia ser.

### O #418 é regressão DESTA branch, e não está em produção

Vale a distinção, porque "hydration mismatch na marca" soa como algo que já
estaria na mão dos clientes:

| | `PublicEnvScript` injeta | servidor lê | resultado |
|---|---|---|---|
| `main` | `env.APP_NAME` / `env.APP_LOGO_URL` | `process.env` | **mesma fonte** — concordam |
| esta branch | marca **resolvida** (banco acima do `.env`) | `process.env` | **divergem → #418** |

A `main` tem a MESMA assimetria em `branding()` — servidor lê `process.env`,
cliente lê `window.__PUBLIC_ENV__` — e mesmo assim não quebra, porque lá as duas
pontas leem o `.env`. A divergência nasceu quando a onda 2 fez o cliente ver o
banco: **consertou metade da fronteira**. Meia travessia é pior que nenhuma —
sem a onda 2 o logo do banco não aparecia; com ela, aparece e quebra a
hidratação de toda tela de `/app`.

Duas hipóteses caíram por medição antes desta, e não vale reabri-las:
`collapsed` vem de cookie lido no servidor (`app/app/layout.tsx:122`), igual dos
dois lados; e `activeOrg` chega como **prop do servidor**
(`hooks/auth/AuthProvider.tsx:25-32`), sem fetch no cliente.

### Correção: o que eu disse sobre as 5 falhas de `test:unit` estava errado

Escrevi, no commit do merge e no corpo do PR:

> "quem tem `UPSTASH_*` **de verdade** no `.env.local` faz o código tentar a rede
> e estourar 15s"

**O `.env.local` deste worktree é cópia BYTE-IDÊNTICA do `.env.example`, com
todos os valores VAZIOS.** Não há `UPSTASH_*` real nenhum. Medido:
`cmp -s .env.local .env.example` → idêntico; `UPSTASH_REDIS_REST_URL=` vazio.

E o mecanismo que inventei também não se sustenta: `lib/ai/dispatcher/rate-limit.ts:22`
faz `if (!url || !token)` e cai no fallback — string vazia é falsy, então o
código **não** tentaria a rede.

O que continua medido, e é só isto:

| ambiente | resultado |
|---|---|
| com `.env.local` | falha |
| sem `.env.local` | passa |

O **mecanismo** fica como **NÃO MEDIDO**. A pista que sobra veio de outra
medição: o `.env.local` com valores vazios derruba dezenas de arquivos de teste
a menos que se exporte `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`
e `SUPABASE_SERVICE_ROLE_KEY` — porque os placeholders do setup usam `??=`, que
**não** sobrepõe string vazia (só `undefined`). Com as três exportadas — a
condição do CI — a suíte fica em **1 falha / 4693 passam**, e a única é
`messages-handler-canal-intermediado`, isolada pelo mesmo experimento de uma
variável (com o arquivo: 1 falha; sem: 11 passam).

O erro aqui não foi errar o palpite — foi **publicar o palpite com a forma de
medição**. A frase dizia "de verdade", que é afirmação sobre o conteúdo de um
arquivo que eu não tinha aberto. Bastava um `cmp`.

### ⚠️ O e2e NÃO prova o conserto do #418 — e quase creditei a ele

Contagem de `Minified React error` nos logs dos três ciclos:

| ciclo | `marca-logo` está | conserto do `branding()` | ocorrências |
|---|---|---|---|
| 1 | no TOPO da parte 2 | não | **14** |
| 3 | no FIM da parte 2 | **não** | **0** |
| 4 | no FIM da parte 2 | sim | **0** |

O erro desapareceu no ciclo **3**, um ciclo ANTES do conserto existir. Quem o
eliminou foi a **reordenação**: com a spec por último, nenhuma tela roda com logo
de instalação gravado, e sem logo no banco as duas pontas leem a mesma coisa —
o mismatch não tem como acontecer.

Se eu tivesse olhado só o ciclo 4, a leitura natural seria "consertei e o CI
confirma". Estaria errado. O verde de hoje vem da ordem das specs, não da
correção — e a ordem é proteção frágil: basta a spec falhar no meio de novo para
o logo ficar gravado.

**O que sustenta o conserto, então:** a sabotagem (voltar a `branding()` deixa 8
asserções vermelhas), a igualdade SSR-vs-cliente do HTML renderizado, e o
`next build`. É prova de MECANISMO. Prova de comportamento — "as 7 telas não
acusam mais #418 com logo gravado" — **não existe**, e só existirá quando alguma
spec exercitar tela de `/app` com logo de instalação no banco.

Isso é dívida declarada, não pendência esquecida: o caso que faltaria é
"navegar em `/app/ai/*` depois de subir logo da instalação e conferir o console".

### Retratação nº 2 sobre o #418 — e agora com o mecanismo medido

Registrei acima que o React #418 sumiu "por causa da reordenação, não do
conserto". **Também estava errado.** A reordenação nunca teve efeito nenhum:

> **O Playwright ordena os arquivos por CAMINHO, não pela ordem em que são
> passados na linha de comando.**

Medido no run 31838253496: `marca-logo.spec.ts` estava escrita por ÚLTIMO na
`SPECS_PARTE_2` e executou em **15º de 23**. A linha de progresso do próprio
run mostra 14 testes completando depois dela:

    ···F°°··············

Então o que eliminou o #418 foi o **`test.afterAll`** que a revisão acrescentou:
ele limpa as DUAS camadas de marca, e as 14 specs seguintes deixaram de ver logo
gravado. O conserto de hidratação continua sem prova de comportamento — isso não
muda —, mas o crédito agora tem dono certo.

**O erro que se repete aqui não é o proxy ruim.** Foi:

1. medir com um regex que contava a palavra "login" → número errado;
2. ser corrigido, re-medir com outro instrumento → número certo;
3. mover a spec com base nele **sem nunca perguntar se mover a spec faz alguma
   coisa**.

Os passos 1 e 2 são sobre precisão. O passo 3 é a falha real: eu refinei a
medição de uma grandeza que não tinha efeito no mundo. O comentário no
`e2e.yml` afirmava "é a última de propósito" — uma frase sobre o comportamento
do Playwright que eu nunca medi, escrita com a mesma confiança das que eu havia
medido.

### O caso (4): nada foi apagado, e o instrumento não sabia dizer o que viu

O vermelho dizia "a recusa apagou o logo — a gravação não foi atômica". É
**impossível por construção**: a recusa por bytes sai da rota com 415 em
`route.ts:399-406`, treze linhas antes da primeira leitura do banco e vinte
antes do primeiro toque no storage.

O que de fato aconteceu, por cadeia de eliminação — cada elo medido:

| # | Fato | Como se sabe |
|---|---|---|
| a | os DOIS `logo_path` seguiam gravados ~6s depois | o `afterAll` clicou "Remover" nas duas camadas e recebeu 200 nas duas; o botão só existe quando a camada TEM logo |
| b | `marcaDaInstalacao()` nunca degradou | zero ocorrências do aviso no log do run, e a sonda está viva |
| c | `collapsed` era `false` | cookie inexistente em contexto novo |
| d | com `logo` truthy a casca SEMPRE desenha `<img>` | `Sidebar.tsx:65,75-76` |
| e | não houve exceção do servidor na janela | os dois `⨯` do log são de outros instantes |

De (a)+(b)+(c)+(d): qualquer render de `/app` teria produzido `aside img`. Logo
**o DOM medido não era a casca do app** — foi redirect ou troca de casca. Qual
delas, **NÃO MEDIDO**.

E não dava para medir, por um detalhe de configuração que vale mais que este
caso: `playwright.config.ts` tinha `trace: "on-first-retry"` com `retries: 0`.
As duas linhas estão certas isoladamente e, juntas, significam **trace nunca
gravado**. O único artefato do run era um `error-context.md` que fotografou a
página do `afterAll`, não a que falhou.

**Suspeito nº 1, não medido e fora do escopo:** `lib/auth/server.ts:34-38`
descarta o erro de `supabase.auth.getUser()`. Qualquer falha transitória contra
o GoTrue vira "não está logado" → `redirect("/login")` em silêncio — e `/login`
põe o logo num `<div>`, não num `<aside>`. A MESMA função trata isso corretamente
sessenta linhas abaixo, com o comentário "degradar permissão em silêncio é o
pior desfecho possível". É a doutrina "falhar fechado na ação, **aberto na
informação**" ferida no primeiro dos três pontos.

**Amostra n=2, com resultados opostos:** no run anterior (`b804e207`) o caso (4)
PASSOU e quem reprovou foi o (5); entre os dois SHAs há um único commit, e ele
só toca documentação. Mesmo binário, vermelho diferente. Chamar de
"determinístico" ou de "flake" com essa amostra seria afirmar sem medir — o que
falta é o trace, e é o que este commit passa a produzir.
