# Migration Manifest — DeskcommCRM

Migrations applied to Supabase project `rrydmwnporysaiysiztn` (sa-east-1, Postgres 17) via Supabase MCP on 2026-04-28.

## ⚠️ Este arquivo é HISTÓRICO — migration nova NÃO acrescenta linha aqui (02/10/2026)

A descrição de migration nova mora **no próprio `.sql`**, numa linha de cabeçalho:

```sql
-- manifest: <o QUÊ e o PORQUÊ, numa linha só>
```

Versão e nome saem do nome do arquivo. O registro completo é esta tabela **mais**:

```bash
grep -m1 '^-- manifest:' supabase/migrations/*.sql
```

**Por quê.** Todo PR com migration acrescentava uma linha no FIM da tabela "Applied". O
`merge=union` do `.gitattributes` resolve isso no git local, mas o GitHub **ignora driver de
merge**: cada migration que entrava na `main` deixava todos os outros PRs com migration
`CONFLICTING`, e o auto-merge parava (medido em 02/10/2026: #2009, #2049, #2078, #2080, #2091 e
#2137, várias vezes cada). Uma linha dentro do arquivo da própria migration não tem com quem
conflitar — e acompanha o arquivo quando ele é renumerado.

O gate é `tests/unit/manifest-x-migrations.test.ts`: reprova migration sem descrição (nem
cabeçalho nem linha aqui), descrita nos DOIS lugares, número repetido e carimbo repetido.
PR aberto antes desta mudança, com linha aqui e sem cabeçalho, continua passando; se a linha
dele conflitar, a resolução é apagá-la daqui e pô-la como `-- manifest:` no `.sql`.

## Nota — renomeação de 4 prefixos em 2026-08-05 (issue #143)

O Supabase CLI usa o **timestamp** (14 dígitos do prefixo) como PK de
`supabase_migrations.schema_migrations`. Quatro pares de arquivos tinham o mesmo timestamp,
então `db push` colidia na PK ao registrar o segundo de cada par e `db reset` quebrava — e
todo fork esbarrava nisso a cada merge com o upstream.

O segundo arquivo de cada par teve o timestamp acrescido de **1 segundo**. Nada mais mudou:
o conteúdo é byte-a-byte o mesmo, e como o CLI ordena alfabeticamente pelo prefixo, a ordem
de aplicação continua idêntica.

| Antes | Depois |
|---|---|
| `20260717190000_0038_webhooks_automation` | `20260717190001_...` |
| `20260718160000_0043_retire_duplicate_lead_trigger_events` | `20260718160001_...` |
| `20260721120000_0055_whatsapp_media_bucket` | `20260721120001_...` |
| `20260722160000_0064_followup_enrollment_exclusivity` | `20260722160001_...` |

Medido em Postgres 17 descartável, registrando as 100 versões em ordem alfabética contra a
PK real: **antes** 4 colisões / 96 registradas; **depois** 0 colisões / 100 registradas.

**Se você já rodava `db push` neste repo:** as quatro passam a ser vistas como pendentes e
serão reaplicadas. As quatro são idempotentes e re-aplicáveis por construção — as duas que
mexem em dados (`0043`, `0064`) casam zero linhas em banco saudável, porque o que elas
corrigem já não é produzido (`0043`) ou já é impedido pelo índice único que elas próprias
criam (`0064`). Quem prefere não reaplicar pode registrar as versões novas à mão:

```sql
insert into supabase_migrations.schema_migrations (version) values
  ('20260717190001'), ('20260718160001'), ('20260721120001'), ('20260722160001')
on conflict (version) do nothing;
```

**Isto não conserta `supabase db reset` em banco novo**, e não é o que esta mudança promete:
a cadeia de `migrations/` não sobe do zero por outro motivo (quebra na `0010`, que altera
tabelas que as migrations-stub `0001`–`0009` nunca criaram — medido: 21 aplicam, 80 falham).
O caminho de instalação suportado é o `supabase/baseline.sql`, que é o que o kit self-host
aplica.

## Applied

| Version | Name | Description |
|---|---|---|
| `20260428195354` | `0001_platform_base` | organizations, user_organizations, platform_admins, api_tokens, api_audit_log, user_recovery_codes, idempotency_keys + RLS helpers (fn_user_org_ids, fn_is_platform_admin, fn_user_role_in_org, fn_role_at_least) |
| `20260428195513` | `0002_event_log_and_compat` | event_log + emit_event/fn_log_event helpers + compat aliases (fn_set_updated_at, fn_user_role_in returning int) |
| `20260428195708` | `0003_customer_360` | contacts (CPF encrypted), crm_pipelines, crm_stages, crm_leads, crm_lead_activities, crm_lead_links, merge_queue + 5 domain triggers |
| `20260428200016` | `0004_whatsapp_waha` | channel_sessions, channel_session_warmup, conversations, messages, webhook_events_log + emit_message_event trigger |
| `20260428200128` | `0005_ai_rag` | ai_agents, ai_knowledge_sources, ai_chunks (vector(1536) ivfflat), ai_knowledge_versions, ai_invocations, ai_pricing (3 seeded), ai_budgets + fn_audit_log_row helper |
| `20260428200211` | `0006_nuvemshop_lgpd` | tenant_integrations, orders, nuvemshop_products, lgpd_requests + fn_encrypt_oauth/fn_decrypt_oauth + LGPD/DLQ extra indexes on webhook_events_log |
| `20260428200331` | `0007_security_hardening` | search_path=public set on all functions, ai_pricing public-read policy, revoke EXECUTE anon on internal helpers, tighten api_audit_log INSERT policy |
| `20260429013958` | `0008_tenant_onboarding_state` | onboarding state machine columns + transitions on organizations |
| `20260429021857` | `0009_expand_messaging_constraints` | extra check constraints + indexes on conversations/messages for inbox perf |
| `20260429032132` | `0010_ai_rag_handoff_columns_and_rpcs` | EPIC-06 wave 1: contacts.force_human + conversations.bot_silenced_until/last_handoff_at + RPC retrieve_top_k_chunks (security definer + programmatic org filter) + RPC activate_kb_version + ai_pricing seed corrections (haiku 100/500, embedding-3-small 20) |
| `20260429040000` | `0011_handoff_reason_column` | EPIC-06 wave 3: conversations.last_handoff_reason (diagnostic) + crm_stages.requires_human (gate G4 — bypass bot when lead enters critical stage) |
| `20260429060000` | `0012_kb_version_lifecycle_columns` | EPIC-06 wave 4: ai_knowledge_versions lifecycle columns — status (building/ready/failed), error_message, indexed_at |
| *(wave 5)* | `0013_ai_faq_items` | EPIC-06 wave 5: ai_faq_items table (RLS via fn_user_org_ids) + name/status/ingested_at columns on ai_knowledge_sources + expanded source_type check |
| `20260429080000` | `0014_storage_policies_ai_policy` | EPIC-06 wave 6: private ai-policy bucket (20MB cap, pdf+md MIME) + per-tenant SELECT/INSERT/DELETE RLS on storage.objects (path-prefix org isolation via user_organizations EXISTS subquery) |
| `20260429090000` | `0015_conversations_rag_optin` | EPIC-06 wave 7 (S-06.07, LGPD L-08): conversations.usable_for_rag + marked_at + marked_by + rag_review_status (tri-state) + partial index on (org, usable_for_rag, marked_at) where true |
| `20260428000000` | `0016_lgpd_emergency_scope` | EPIC-08 wave 3: lgpd_requests.emergency (boolean, default false) + scope (text check 'contact'/'tenant', default 'contact') + partial index lgpd_requests_emergency_idx on (org, emergency, due_at) where emergency=true |
| `20260429100000` | `0017_storage_policies_lgpd_exports` | EPIC-08 wave 4 (S-08.04): private lgpd-exports bucket (50MB cap, pdf+json MIME) + per-tenant SELECT RLS on storage.objects (path-prefix org isolation via user_organizations EXISTS subquery). Worker uploads via service-role only — no INSERT/DELETE policies for anon/authenticated. |
| `20260429110000` | `0018_lgpd_redaction_queue` | EPIC-08 wave 5 (S-08.05): storage_redaction_queue table (org-scoped RLS, unique (bucket, object_path), partial index on pending) — async drain target for LGPD media deletion. |
| `20260429110001` | `0019_lgpd_cascade_redact_rpc` | EPIC-08 wave 5 (S-08.05, L-04): SECURITY DEFINER fn_lgpd_cascade_redact_contact(org, contact, request) — atomic 8-step cascade (contacts irreversible + conversations + messages + activities + leads + orders payload strip + media enqueue + audit). ACL revoked from anon/authenticated; granted only to service_role. |
| `20260429120000` | `0020_organization_suspend_reason` | EPIC-11 wave 8 (S-11.08): organizations.suspended_reason (text) + suspended_by (uuid → auth.users) — enables suspend/reactivate API to store reason + actor. |
| *(wave 11)* | `0021_incidents` | EPIC-11 wave 11 (S-11.11): incidents table (organization_id optional FK, severity check info/warning/critical, status open/acknowledged/resolved, payload jsonb, acknowledged/resolved actor+timestamp, resolution_note, RLS via fn_is_platform_admin — platform-admins only, users do not see). Indexes on status+created_at (partial, excludes resolved), org+created_at, severity+status. |
| `20260429140000` | `0022_ai_budget_trigger` | EPIC-06 finalize: ai_budget enforcement trigger on ai_invocations. |
| `20260505140000` | `0023_ai_agents_module` | EPIC-13 wave 1 (S-13.01): foundation schema for configurable AI Agents — extends ai_agents (published_version_id, priority, archived_at, kind), creates ai_agent_versions, ai_provider_credentials (+ safe view, security_invoker), ai_agent_runs (partial unique one_running_per_conv), ai_models (global catalog, 8 models seeded). RLS via fn_user_org_ids on tenant-aware tables; audit triggers via fn_audit_log_row (helper inlined idempotently — was missing from local migrations although present on remote since 0005). ai_models is global read-all, write via service role only. |
| `20260506000000` | `0024_ai_agent_publish_fn` | EPIC-13 S-13.06: fn_publish_ai_agent_version (atomic Save/Publish flip) — validates agent/version/credential/channel_session/model before flipping published_version_id. |
| `20260506100000` | `0025_fix_publish_fn_and_realtime_publication` | Forward-fix: qualifies column refs in fn_publish_ai_agent_version to resolve `agent_id` ambiguity against RETURNS TABLE output params; adds ai_agent_runs/ai_agents/ai_knowledge_sources to the supabase_realtime publication. |
| `20260706200000` | `0026_fix_publish_fn_status_case` | Forward-fix: fn_publish_ai_agent_version compared channel_sessions.status against lowercase `'working'`, but the canonical value (channel_sessions_status_check, written by the WAHA webhook handler) is uppercase `'WORKING'`. Publish always raised `channel_session_offline`, even for a genuinely connected session. Bug present since 0024, carried forward unchanged by 0025. |
| `20260706210000` | `0027_whatsapp_conversation_unification` | Bugfix de governança de conversas WhatsApp. Causa-raiz: contatos @lid sem unique key + resolução check-then-act, e o WAHA emitindo `message`+`message.any` por mensagem → 1 pessoa virava N contatos/conversas (medido: 1 lid = 12 contatos). Adiciona coluna gerada `contacts.wa_identity` (`phone:+E164`/`lid:<digits>`), faz merge idempotente do histórico duplicado repontando todas as FKs (usa `is_merged_into` como mapa, sem temp tables → portável em psql), cria `uniq_contacts_org_wa_identity` + `uniq_conversations_1to1_per_contact_session` (a antiga unique incluía group_chat_id NULL, que no Postgres não protege 1:1), e as funções de upsert atômico `fn_upsert_wa_contact`/`fn_upsert_wa_conversation`/`fn_mark_conversation_message` que a app passa a usar (lib/waha/ingest.ts) no lugar do check-then-act. Também corrige mapeamento de `type` WAHA→CRM (`chat`→`text` etc.) que fazia mensagens reais violarem messages_type_check e sumirem. |
| `20260716120000` | `0030_config_rls_role_policies` | G2-03 (gov-loop): RLS por role nas tabelas de config, fechando no banco o que a G2-01 fechou na API (spec 13 §4; auditoria em §4.1). crm_pipelines/crm_stages: policy ALL org-flat vira SELECT org + WRITE manager+ (`fn_role_at_least`, padrão de api_tokens/merge_queue) — agent deixa de escrever config de pipeline. conversations: WRITE vira agent+ (viewer é read-only); SELECT permanece org-flat com a mesma expressão (escopo own/unassigned é G4-01). NNNN pula 0028/0029 (ocupados nas branches vendaval/F2-*). Sem mudança de contrato — database.types.ts intocado. |
| `20260717120000` | `0031_conversation_assignment_events` | G3-01 (gov-loop): conversation_assignment_events (spec 13 §3.1 — org, conversation, from/to, changed_by, reason claim\|transfer\|release\|routing\|handoff; RLS org SELECT+INSERT, append-only sem UPDATE/DELETE, índice por conversa) + fn_conversation_assign (SECURITY INVOKER: SELECT FOR UPDATE + UPDATE condicional de assigned_to_user_id + INSERT do evento na MESMA transação; 0 rows = lock perdeu → rota 409; unread_count_for_assignee re-zerado; changed_by = auth.uid(), nunca input do caller). Rotas claim/release migram pra função; rota transfer nova. |
| `20260717150000` | `0032_conversation_assignee_kind` | G3-02 (gov-loop): IA como assignee de 1ª classe (spec 13 §3.2) — conversations.assignee_kind ('user'\|'ai') com CHECK de coerência em implicação (kind='user' ⇒ assigned_to_user_id not null; kind='ai' ⇒ null; kind null livre p/ escritas legadas), backfill ANTES da constraint (assigned ⇒ 'user'; status='ai_handling' sem dono ⇒ 'ai'). Forward-fix INB-06a: fn_conversation_assign valida DENTRO da função que o destino é membro ativo agent+ da MESMA org (helper novo fn_member_role_in_org, SECURITY DEFINER — RLS de user_organizations não mostra membership alheio a agent) e passa a manter assignee_kind coerente em claim/transfer/release/handoff. Handoff IA→humano (lib/mcp/tools/handoff.ts) vira reassignment auditado reason='handoff'. |
| `20260717190000` | `0034_hardening_revoke_anon_definer` | G4-00 (gov-loop): defesa em profundidade (INB-07) — nega EXECUTE a anon em 6 funções SECURITY DEFINER de escrita. Duas origens de grant: (A) fn_upsert_wa_contact/fn_upsert_wa_conversation/fn_mark_conversation_message já sem grant a PUBLIC no baseline, anon herda só do ALTER DEFAULT PRIVILEGES ... TO anon → `revoke execute from anon`; (B) emit_event/fn_log_event/fn_audit_log_row criadas antes do ALTER e nunca revogadas de PUBLIC, anon herda via PUBLIC → `revoke execute from public` + re-afirma grants de authenticated/service_role. Sem os revokes o PostgREST exporia estas RPCs à anon key pública. Call sites auditados: service_role (webhook WAHA ingest, workers, cron via admin.rpc) ou authenticated (.rpc em rota logada) — nenhum fluxo anônimo. authenticated/service_role mantêm EXECUTE. Sem mudança de contrato → database.types.ts intocado. |
| `20260717210000` | `0036_visibility_mode_lead_rls` | G4-03 (gov-loop): eixo 5 — escopo de visualização/escrita por atendente no kanban/leads (spec 13 §4 linha 220). Espelha a G4-01 (conversations, 0035) para `crm_leads`: o "dono" do lead é `owner_user_id` (NÃO assigned_to — isso é conversa). REUSE do mesmo `organizations.settings.visibility_mode` ('all'\|'own_and_unassigned'\|'own', default 'own_and_unassigned' — decisão G1-06a; a matriz §4 diz "mesmo escopo da G1-06a"). `fn_can_view_lead(p_org, p_owner_user_id)` = lógica pura (role via `fn_user_role_in_org` + visibility_mode + owner_user_id), STABLE SECURITY DEFINER, search_path blindado, EXECUTE revogado de anon/public e concedido só a authenticated/service_role (lição G4-00); recebe os campos da ROW — sem lookup/recursão. Só o role `agent` é restrito; viewer/manager/admin org-wide read; platform_admin tudo. A FOR ALL org-flat `tenant_isolation_crm_leads_all` governava SELECT junto (USING OR-ado) — dropada e re-expressa por-comando: `crm_leads_select` (fn_can_view_lead) + escrita por-role `crm_leads_insert/update/delete` (agent=own-scope via a MESMA fn — "mesmo escopo" da matriz; manager+=org-wide via piso `fn_role_at_least 'manager'`; viewer=none via piso `'agent'`). Drag-and-drop do kanban (UPDATE de stage_id/position de lead PRÓPRIO, sem mudar owner) passa: owner=uid ⇒ fn true ⇒ USING+WITH CHECK ok; mover lead de outro agent bloqueado (own:write); bulk assign G3-04 (≥manager) intacto (org-wide). Board (`GET /pipelines/[id]/board`) já usa client cookie user-scoped ⇒ a RLS nova filtra automaticamente e os contadores por stage derivam do array RLS-scoped (coerentes, sem contar o invisível) — sem mudança de código de app. Contrato muda (fn nova em Functions) → database.types.ts atualizado à mão. |
| `20260718140000` | `0040_conversation_routing_emit` | G5-02 (gov-loop): eixo 4 — AT-03, distribuição automática de conversas sem dono (spec 13 §5). Trigger `trg_conversation_routing_requested` AFTER INSERT em conversations chamando `fn_emit_conversation_routing()` (SECURITY DEFINER) → `emit_event('conversation.routing_requested', ...)` em event_log. Trigger NUNCA faz HTTP — só emite a linha; o worker (cron TS `lib/routing/worker.ts` `runRoutingWorker()`, rota `POST/GET /api/v1/cron/routing-worker`) consome, resolve `organizations.settings.routing.mode` e atribui via `fn_conversation_assign(reason='routing', p_expected_assignee=null, p_enforce_expected=true)` — optimistic lock null = idempotência (replay/corrida não reatribui, 0 rows). round_robin = rodízio real entre elegíveis (isAttendantEligible de 0039 + carga atual + última atribuição derivada de conversation_assignment_events; select puro em lib/routing/decide.ts), sem elegível re-agenda com backoff de settings.routing (não hardcoded), estourou max_retries → status='dead' (fica na fila, G5-03 mostra); manual = no-op; 'load' inalcançável (schema só manual|round_robin) tratado defensivamente. ANTI-ECO (crítico): AFTER INSERT APENAS + WHEN (assigned_to_user_id IS NULL AND status IN ('open','pending')) — sem trigger de UPDATE, então o UPDATE de atribuição do worker (owner null→agente) JAMAIS re-emite routing_requested (senão loop infinito). Sem mudança de contrato (nenhuma RPC nova chamável; fn_emit retorna trigger) → database.types.ts intocado. NNNN=0040 (0039 é G5-01, 0038 colega webhooks, 0050+ vendaval — verificado contra todas as branches/worktrees). |
| `20260718160000` | `0042_lead_children_visibility_rls` | G6-00 (gov-loop, INB-10): fecha o vazamento de LEITURA da timeline (`crm_lead_activities`) e dos vínculos (`crm_lead_links`) de lead — seguiam org-flat no SELECT, então um `agent` em modo 'own' não via o lead (fechado em 0036) mas lia as activities/links dele por query direta. Pré-condição da exposição MCP (G6-03). FIX: SELECT das tabelas-filhas passa a HERDAR a visibilidade do lead-pai via a MESMA `fn_can_view_lead` (0036), por `EXISTS` no `lead_id` (`exists (select 1 from crm_leads l where l.id = <tabela>.lead_id and fn_can_view_lead(l.organization_id, l.owner_user_id))`) — servido por `idx_lead_activities_org_lead_perf` / `idx_crm_lead_links_lead`. CUIDADO (lição G4-01): EXISTS, NÃO scalar-subquery de owner — sob RLS um scalar devolveria NULL pro lead oculto e o modo `own_and_unassigned` trataria NULL como "fila" ⇒ vazamento; o EXISTS fecha (linha oculta ⇒ 0 rows ⇒ false). WRITE = org-scope IDÊNTICO ao de hoje (defesa em profundidade, NÃO o vetor do INB-10, que é de LEITURA): todo escritor real de timeline/vínculo (MCP `handoff.ts`, rota LGPD, código) usa SERVICE ROLE e bypassa RLS; `crm_lead_activities` é append-only (sem policy update/delete); restringir o write por visibilidade arriscaria o emissor polimórfico sem fechar nada novo, então o WITH CHECK/USING de escrita é preservado byte-a-byte (org-membership OR platform_admin). `crm_lead_links` era `tenant_isolation_crm_lead_links_all` FOR ALL (o USING de FOR ALL permissiva governa SELECT via OR — a armadilha G4-01) → dropada e re-expressa POR-COMANDO (select/insert/update/delete, sem FOR ALL). `crm_lead_activities` já era por-comando (select+insert) — só o SELECT muda a semântica. Sem mudança de contrato (só RLS) → database.types.ts intocado. NNNN=0042 (0041 tomado por `fix/webhooks-secret-encryption`, 0050+ vendaval — verificado contra todas as branches). |
| `20260718130000` | `0039_attendant_availability` | G5-01 (gov-loop): eixo 4 — config de roteamento + disponibilidade/horário por atendente (spec 13 §3.4/§3.5/§5). Tabela nova `attendant_availability` (organization_id, user_id, is_available, capacity int >0, schedule jsonb tz-aware `{timezone, windows:[{dow,start,end}]}`, last_heartbeat_at, updated_at, unique(org,user)) — persiste o `<AttendantStatusToggle>`/heartbeat da spec 04 §8 (100% ausente antes — Apêndice B). RLS por-comando (NUNCA FOR ALL — o USING de FOR ALL permissivo governa SELECT via OR e vazaria escopo, lição G4-01): `attendant_availability_select` org-wide (fn_user_org_ids — disponibilidade da equipe visível a todo membro p/ roteamento, matriz §4 nota 5); `attendant_availability_insert/update/delete` = própria linha (user_id=auth.uid() + membro) OU manager+ (fn_role_at_least 'manager'); service_role (worker de heartbeat) bypassa. Índice parcial `idx_attendant_availability_available` (organization_id) WHERE is_available — varredura de elegibilidade/roteamento fica pequena. `settings.routing` (§3.5) fica em `organizations.settings` jsonb (já existe — sem coluna nova; DIRC Reuse), validado por Zod declarativo `routingConfigSchema` (mode manual|round_robin default manual — round_robin opt-in G1-06b; knobs max_retries/backoff_seconds = CONFIG lida pelo worker de G5-02, nunca hardcoded). Elegibilidade (§5: disponível ∧ dentro do horário ∧ abaixo da capacidade) = TS puro `lib/routing/eligibility.ts` (o worker G5-02 é TS e importa; tz-aware via Intl.DateTimeFormat), não fn SQL. AT-08 auto-offline 15min = cron TS `GET /api/v1/cron/attendant-heartbeat` (UPDATE is_available=false onde last_heartbeat_at < now−15min; trigger NUNCA faz HTTP) — staleness = fn TS pura testada com clock mockado, 15min é constante nomeada única. NNNN=0039 (0038 tomado por feat/webhooks-automation; pula 0028/0029/0038 e 0050 vendaval, verificado contra todas as branches). Contrato muda (tabela nova) → database.types.ts atualizado à mão. |
| `20260718120000` | `0037_attendant_metrics` | G4-04 (gov-loop): métricas por responsável (spec 13 §6 — definições congeladas ANTES do código). `fn_attendant_metrics(p_org, p_from, p_to, p_owner)` = agregação SQL **SECURITY INVOKER** (default) → jsonb `{funnel, attendants}`: o escopo por atendente é a PRÓPRIA RLS de crm_leads (0036) e conversations (0035), não uma checagem paralela — agent agregando colapsa aos próprios (own-scope G1-06a), manager+ org-wide + filtro opcional `p_owner`. Métricas: funil por stage (snapshot, sem janela, §6.2); leads ganhos/perdidos por owner na janela de `closed_at` (§6.3/§6.4); conversas atendidas por assignee na janela de `assigned_at` (§6.5); tempo até 1ª resposta HUMANA por assignee (§6.6) = média de (1ª outbound com `sent_by_user_id IS NOT NULL` − 1ª inbound), por `sent_at`; o bot/IA (`sent_via='ai'`, `sent_by_user_id NULL`) é excluído pela coluna `sent_by_user_id`. Janela semiaberta [from,to). Dois índices parciais dedicados (spec 13 §6.7): `idx_crm_leads_org_status_closed_owner` (organization_id, status, closed_at, owner_user_id) WHERE closed_at IS NOT NULL — won/lost por owner; `idx_conversations_org_assignee_assigned` (organization_id, assigned_to_user_id, assigned_at) WHERE assigned_to_user_id IS NOT NULL — conversas por assignee (org-leading). TTFR reusa idx_messages_conversation_sent. EXPLAIN sob role agent E manager (RLS ativa muda o plano) prova ausência de seq scan em crm_leads/conversations/messages. NNNN pula 0028/0029 (branches vendaval/F2-*). Contrato muda (fn nova em Functions) → database.types.ts atualizado à mão. |
| `20260719000000` | `0050_agent_harness` | Fusão Vendaval → DeskcommCRM (Fase 0): schema completo do motor SDR (agent-engine) no banco canônico — 31 tabelas org-scoped chaveadas em organizations/contacts/conversations/channel_sessions. Fila durável (`job_queue`, FOR UPDATE SKIP LOCKED, lane por contact), ledger de envio idempotente (`send_ledger`, unique (job_id,seq)), memória do agente (`lead_checkpoints`, `lead_notes`+embedding jsonb, `lead_state`+transitions), config versionada por ponteiro com trigger de imutabilidade compartilhada `fn_agent_versions_immutable` (playbooks, skills, promise table, disclosure, reentry templates/knobs), anti-ban (`channel_knobs`, `pacing_ledger`, `outbound_copies`, `channel_session_health`), auditoria (`before_send_traces`, `llm_calls`, `metrics`), escalação humana (`agent_inbox_items`, org nullable = plataforma), cron por contato (`cron_jobs`), flywheel (`flywheel_judge_verdicts`, `flywheel_distiller_proposals`, `judge_alignment_pool`) e cursor de consumo (`watchdog_cursors`, RLS sem policy = só service role). RLS padrão `tenant_isolation_<t>_all` via fn_user_org_ids + revoke anon em todas. Mapeamento canônico em lib/agent-engine/PORT-NOTES.md (tenants→organizations, leads→contacts; org_llm_credentials NÃO portada — BYOK é ai_provider_credentials). Worker acessa via role dedicada `agent_worker` (login+bypassrls, criada fora da migration — credencial nunca em arquivo versionado). APLICADA no projeto hospedado em 2026-07-17 via supabase db push (junto com 0034–0037 que estavam pendentes de push). |
| `20260719100000` | `0051_agent_version_immutability` | Fase 2B da fusão: trigger `trg_ai_agent_versions_content_immutable` — conteúdo de `ai_agent_versions` fora de `status='draft'` é imutável no BANCO (system_prompt, provider/model/credential, tools, trigger_config, channel, limites, handoff, identidade). Lifecycle (status/published_at/superseded_at) segue livre — é o flip do `fn_publish_ai_agent_version`. Motivo: o agent-engine passa a ler a versão PUBLICADA por ponteiro a cada turno; versão publicada mutável quebraria o contrato versões-imutáveis+ponteiro (princípio do harness 0050). |
| `20260719110000` | `0052_republish_fn_uppercase_fix` | Forward-fix de DRIFT de função (achado publicando pela tela na Fase 2B da fusão): `fn_publish_ai_agent_version` no hosted continha a versão pré-0026 (`v_session.status <> 'working'` minúsculo) apesar da 0026 constar aplicada — algo re-assentou a definição antiga por fora do fluxo de migrations. Esta migration re-assenta a definição correta da 0026 (comparação com 'WORKING'). Idempotente (`or replace`); o baseline já continha a versão correta — o apêndice aqui é o re-assentamento defensivo. |
| `20260717200000` | `0035_visibility_mode_conversation_rls` | G4-01 (gov-loop): eixo 5 — escopo de visualização por atendente (spec 13 §3.5 + §4). `organizations.settings.visibility_mode` ('all'\|'own_and_unassigned'\|'own', default 'own_and_unassigned' — decisão G1-06a quando a chave falta) restringe o SELECT de conversations/messages APENAS para o role `agent`; viewer/manager/admin seguem org-wide read; platform_admin tudo. `fn_can_view_conversation(p_org, p_assigned_to_user_id)` = lógica pura (role via `fn_user_role_in_org` + visibility_mode + assigned_to), STABLE SECURITY DEFINER, search_path blindado, EXECUTE revogado de anon/public e concedido só a authenticated/service_role (lição G4-00); recebe os campos da ROW (a policy passa organization_id + assigned_to_user_id) — sem lookup/recursão por-row. `conversations_select` passa a chamar a fn. A escrita 0030 (`conversations_agent_write`) era FOR ALL, e o USING de um FOR ALL permissivo TAMBÉM governa SELECT (policies OR-adas) — deixá-la anularia o visibility; por isso é re-expressa por-comando (`conversations_agent_insert/update/delete`, MESMO agent+/org — quem escreve não muda), removendo só o grant implícito de SELECT. `messages`: `messages_tenant_isolation_all` (FOR ALL org-flat) vira `messages_select` (herda o escopo da conversa via `exists` na RLS de conversations — conversa oculta ⇒ 0 rows ⇒ mensagem oculta; scalar subquery de assigned_to seria vazamento pois RLS devolve NULL) + `messages_insert/update/delete` org-flat (escrita não restringida por visibility; ingestão/outbound via service_role bypassa RLS). Realtime (postgres_changes) herda a policy de SELECT → subscription do inbox não entrega conversa fora do escopo. Forward-fix necessário: `fn_conversation_assign` (0031/0032) vira SECURITY DEFINER — com o SELECT visibility-aware, o `update ... returning *` re-aplicava a policy de SELECT à nova linha e a transferência (dono → outro atendente, invisível ao autor) quebrava com "new row violates row-level security policy"; DEFINER bypassa a RLS interna e um guard novo re-afirma a autz do caller (agent+ ativo da org; service_role/MCP com auth.uid() null dispensado). NNNN pula 0028/0029 (branches vendaval/F2-*). Contrato muda (fn nova em Functions) → database.types.ts atualizado à mão. |
| `20260717180000` | `0033_conversation_tags` | G3-05 (gov-loop): eixo 7 — tags de conversa (spec 13 §3.3). `conversations.tags text[] not null default '{}'` + índice GIN `idx_conversations_tags_gin`, mesmo shape de contacts.tags/crm_leads.tags (DIRC: Create reusando o padrão; NÃO reuso de contacts.tags — tag de contato = pessoa duradoura, tag de conversa = atendimento episódico). Vocabulário canônico em `organizations.settings.canonical_conversation_tags` (array jsonb, org-scoped, não pipeline-scoped), semeado com default pt-br de e-commerce só onde a chave falta (auto-curativo). PATCH /api/v1/conversations/[id] passa a aceitar `tags` (Zod: trim+lowercase+dedup, ≤20 tags, cada ≤40 chars) e audita `conversation.tags_changed`; lista aceita filtro `tag` (org-scoped, `tags @> array[tag]`). Contrato muda → database.types.ts atualizado à mão (coluna tags em conversations Row/Insert/Update). |
| `20260718170000` | `0044_user_orgs_select_manager` | G6-06 (gov-loop, INB-14): SELECT de `user_organizations` org-wide para manager+ (matriz spec 13 §4: team=org:read a manager). A `user_orgs_select` dava org-wide read só ao admin — manager caía no self-read e `GET /api/v1/team` devolvia 1 membro (aba Membros quebrada). FIX cirúrgico: threshold `'admin'`→`'manager'` no SELECT (`(user_id = auth.uid()) OR fn_role_at_least(organization_id, 'manager') OR fn_is_platform_admin()`). Self-read preservado p/ TODOS (viewer/agent leem a própria linha — auth/RBAC/`fn_user_org_ids` dependem disso). WRITE INALTERADO: `user_orgs_insert/update/delete` seguem `fn_role_at_least(org,'admin')` — não tocados. Sem recursão: `fn_role_at_least` é STABLE SECURITY DEFINER (bypassa a RLS de user_organizations internamente); a policy já a chamava no SELECT, trocar o threshold mantém a propriedade. Cross-org seguro: threshold por-org (manager da org A não vira manager da org B → 0 rows da org B). Workaround service-role do `/attendants/availability` (G5-04) intacto (bypassa RLS). Sem mudança de contrato (só RLS) → database.types.ts intocado. NNNN=0044 (0040/0042 gov-loop, 0041/0043 colega, 0050+ vendaval — verificado contra todas as branches). |
| `20260717190001` | `0038_webhooks_automation` | Webhooks universais + mini motor de regras (spec docs/superpowers/specs/2026-07-17-webhooks-design.md): `webhook_sources` (fontes de captação inbound, path_token único, field_map jsonb, pipeline/stage default), `automation_rules` (trigger_event + conditions jsonb AND + actions jsonb ordenadas, is_active default FALSE — regra nasce pausada), `automation_rule_runs` (1 linha por execução, actions_result jsonb, alimenta a UI de Atividade). RLS padrão 0030: select membro-da-org/platform-admin; write manager+ nas duas tabelas de config; runs é select-only (escrita via service_role). Índice parcial `idx_automation_rules_org_trigger` WHERE is_active p/ o hot path do motor. |
| `20260718150000` | `0041_webhook_secret_encryption` | Cifragem at-rest dos secrets de webhooks (retrofit da spec §10, dropada no plano da 0038): `webhook_sources.secret text` → `secret_encrypted bytea` via `fn_encrypt_oauth` (mesma infra pgp_sym/GUC `app.nuvemshop_oauth_key` do Nuvemshop/WAHA), coluna plaintext DROPADA; `automation_rules.actions[].config.secret` (call_webhook) → `config.secret_enc` (hex do bytea). Data-fix condicional: com a GUC presente cifra os existentes; sem a GUC descarta com WARNING (feature recém-nascida, re-configurável pela UI — manter plaintext seria manter o problema). Write cifra via admin RPC (grant só service_role) e falha 422 sem chave; read decifra com fallback hmacSkipped (precedente WAHA). |
| `20260718160001` | `0043_retire_duplicate_lead_trigger_events` | Trigger `trg_emit_event_on_lead_change` para de emitir `lead.created`/`lead.stage_changed` (duplicavam as emissões dos handlers com entity_kind='lead' e payload pobre; nenhum consumer — verificado). Mantém `lead.won/lost/reopened/assigned` (únicos, futuros consumers de notificação). Trigger agora AFTER UPDATE apenas. Higiene: duplicatas pendentes antigas marcadas 'done' (backlog morto que o drain ignoraria pra sempre). |
| `20260720100000` | `0053_flywheel_proposal_applied` | Épico Operação Visível (F3): colunas `applied_at`/`applied_version_id`/`applied_by` em `flywheel_distiller_proposals` — rastro de aplicação de proposta como ai_agent_version nova via publish-por-ponteiro (gate humano = clique de aplicar; null = pendente; base da idempotência do endpoint apply). Sem RLS nova (tabela já org-scoped na 0050). NNNN=0053 (0050–0052 vendaval; verificado contra todas as branches). Contrato: colunas novas lidas via service role — database.types.ts não regenerado (tabela fora do types atual). |
| `20260721120000` | `0054_followup_flows` | Task 1.1 do sistema de follow-up (spec 2026-07-21): 4 tabelas novas — `followup_flow_versions` (grafo versionado imutável, jsonb), `followup_flow_pointers` (ponteiro nome-único por org, draft/active/disabled, handoff_policy, trigger_config), `followup_enrollments` (execução por contato: current_node_id, status com relógio — `next_eval_at` obrigatório em active/waiting_reply e proibido nos terminais/pausado via CHECK —, claimed_until/attempts/outcome), `followup_enrollment_events` (trilha append-only, idempotency_key único por enrollment). Índices: `idx_followup_enrollments_due` (fila do worker), `idx_followup_enrollments_one_live` (unique parcial — 1 enrollment vivo por pointer+contact), `idx_followup_enrollments_contact`. RLS padrão `tenant_isolation_<t>_all` via fn_user_org_ids nas 4 tabelas. `fn_claim_due_followup_enrollments(p_limit, p_lease_seconds)` — claim atômico SKIP LOCKED (mesmo padrão de `job_queue` da 0050), EXECUTE revogado de public/anon/authenticated (só service_role/worker). Não aplicada no banco dev nesta task (API tasks subsequentes aplicam); database.types.ts não regenerado ainda — pendente. |
| `20260721130000` | `0056_followup_version_lineage` | Fix pós-review da Task 3.1 (API de fluxos): `followup_flow_versions.pointer_id` (FK, nullable, backfill genérico via `active_version_id` — versions nunca promovidas ficam null, aceitável) + índice `idx_followup_versions_pointer`. `fn_publish_followup_flow_version(p_org, p_pointer, p_graph, p_created_by)` — publish atômico (insert version + update pointer numa função só, mesmo padrão de `fn_publish_ai_agent_version` 0024/0026), EXECUTE revogado de public/anon/authenticated (só service_role — rota chama via admin client). Habilita rollback a validar linhagem real (`version.pointer_id = pointer.id`), não só "mesma org". |
| `20260721140000` | `0057_followup_dead_inbox_kind` | Task 4.1 (engine de follow-up — worker tick): estende o CHECK de `agent_inbox_items.kind` com `'followup_dead'` (drop + re-add do constraint default `agent_inbox_items_kind_check`, idempotente) para o motor avisar a operação quando um enrollment esgota `attempts` ou estoura `max_steps` sem cair no `'other'` genérico. Sem alteração de dados existentes. |
| `20260722160001` | `0064_followup_enrollment_exclusivity` | Task 8.6 (furo anti-spam achado pelo Rafael): UM follow-up vivo por lead ORG-WIDE. O índice unique-live `idx_followup_enrollments_one_live` deixa de ser `(pointer_id, contact_id)` (impedia dup só no MESMO fluxo) e passa a `(organization_id, contact_id)` — um contato silencioso que casa N fluxos de silêncio parava de ser enrollado em N sequências paralelas. **DEDUP FIRST (self-host-safe):** para qualquer `(organization_id, contact_id)` com >1 enrollment vivo (`status in active/waiting_reply/paused_handoff`), mantém o de `started_at` mais recente e cancela os demais (`status='cancelled', cancel_reason='exclusivity_backfill', next_eval_at=null`) via window function genérica ANTES de criar o índice novo (senão o `update.sh` de um clone com dados sujos quebra). Também adiciona `followup_enrollments.agent_id uuid references ai_agents(id) on delete set null` (+ índice) — qual agente publicado ARMOU o fluxo, pra fila mostrar e a persona ser fixada. |
| `20260723130000` | `0065_reconcile_inbox_kind_check` | Forward-fix pós-merge feat/followup-flows↔main: 0057 (`followup_dead`) e 0062 (`snooze_expired`) redefiniam a MESMA constraint `agent_inbox_items_kind_check` via drop/re-add — a última (0062) vencia e derrubava `followup_dead`, quebrando o markDead do motor. Reconcilia com a UNIÃO de todos os kinds. Idempotente. |
| `20260722150000` | `0061_agent_followup_selector` | Task 7.2 (sistema de follow-up — seletor de fluxo no editor do agente): `ai_agent_versions` ganha coluna `followup jsonb not null default '{"enabled":false,"flow_pointer_ids":[]}'::jsonb` (achado ao investigar o schema real: a tabela guarda config por COLUNA — `trigger_config`/`handoff_keywords` etc. —, não um jsonb único, então o campo novo precisa de coluna própria e migration real, ao contrário da suposição inicial do brief). `fn_ai_agent_version_content_immutable()` (0051) re-assentada (`create or replace`, forward-fix idempotente) incluindo `followup` no veto de mutação de versão publicada — senão o campo ficaria mutável por fora do contrato de imutabilidade que o resto da tabela já respeita. `lib/followup/agent-followup-gate.ts` lê essa coluna (gate: um gatilho AUTOMÁTICO de follow-up só enrolla se algum agente publicado da org tiver o pointer habilitado) — sem consumidor ainda (Onda 8/Task 8.1 é quem cria enrollments por gatilho automático). |
| `20260721120001` | `0055_whatsapp_media_bucket` | Bucket privado `whatsapp-media` (50MB) p/ persistir binários de mídia do WhatsApp (Onda 0 inbox-multimodal). NNNN=0055 (0054 já usado por `feat/followup-flows`, verificado contra todas as branches locais). |
| `20260722120000` | `0058_media_multimodal` | Colunas `media_derived_text`/`media_derived_status` em messages + flags `multimodal_input`/`video_frames_enabled` em ai_agent_versions (Onda 3 agente multimodal). NNNN=0058 (0056/0057 tomados por `feat/followup-flows`, verificado contra todas as branches/worktrees locais). |
| `20260722130000` | `0059_agent_split_messages` | Colunas `split_messages`/`split_max_chars` em ai_agent_versions (Onda 4 split de mensagens). |
| `20260722140000` | `0060_message_templates` | Tabela `message_templates` (templates de script do vendedor, pessoal/compartilhado, RLS) — Onda 5. |
| `20260722160000` | `0062_conversation_snooze` | Colunas snooze_* em conversations + kind snooze_expired em agent_inbox_items (Onda 5.3). NNNN=0062 (0061 tomado por `feat/followup-flows` concorrentemente — verificado contra todas as branches/worktrees locais). |
| `20260723120000` | `0063_conversation_notes` | Tabela `conversation_notes` (notas internas de conversa, visíveis só ao time, RLS select/write) — Onda 5.2. |
| `20260724000000` | `0066_human_cases` | Tabelas `agent_cases` + `agent_case_events` (loop assíncrono IA↔humano, spec 15) + `ai_agent_versions.cases_enabled` + CHECKs de `job_queue.kind`/coerência e `cron_jobs.job_kind` estendidos p/ `case_reply_turn` — Wave 1 casos-humanos. NNNN=0066 (0064/0065 tomados na `main`/`feat/followup-flows` concorrentemente — verificado contra todas as branches remotas). |
| `20260724010000` | `0067_org_memory` | Épico Harness (F1): memória geral da org — `org_memory_versions` (doc-mãe imutável, padrão playbook 0004), `org_memory_pointers` (1 ponteiro/org), `org_memory_entries` (aprendizados manual/flywheel, status proposed/active/archived, FK proposal_id). Check de `flywheel_distiller_proposals.type` ganha `org_memory_entry`. RLS `tenant_isolation_*_all` nas 3 tabelas. NNNN=0067 (último era 0066 — `20260724000000_0066_human_cases.sql` em outra branch local; verificado via `git ls-tree` em TODAS as branches, não só `main`/`HEAD`). `database.types.ts` regenerado. |
| `20260724120000` | `0068_skills_marketplace` | Épico Harness (F2): `skill_versions` ganha `manifest` (lista de arquivos {path,size,sha256,kind}) + `forked_from_version_id`; tabela `skill_activations` (telemetria hard/probe por turno); bucket `skill-assets` (privado, 5MB); policy SELECT de catálogo (`organization_id is null`) em skill_versions/pointers p/ o marketplace ser legível user-scoped. RLS `tenant_isolation` em skill_activations. NNNN=0068 (maior real era 0067; verificado em todas as branches). `database.types.ts` regenerado. |
| `20260724130000` | `0069_seed_platform_skills` | Épico Harness (F2): seed de 2 skills de plataforma (`organization_id null`) no catálogo do marketplace — `objecao-preco` (contornar objeção de preço, vendas/genérico) e `agendamento` (marcar/remarcar horário, clínicas/serviços). Cada uma com body markdown if-then (≤200 linhas) + `matcher` `{any_keywords, probe_keywords}` validado por `skillMatcherSchema` (app-only, sem CHECK no banco). Idempotente via guard `where not exists (select 1 from skill_pointers where organization_id is null and name=...)` — reaplicar não duplica versão nem ponteiro (provado: 2 pointers/1 versão cada, antes e depois do re-apply). NNNN=0069 (maior real era 0068; sem colisão nas branches locais). Sem mudança de contrato de schema — `database.types.ts` não regenerado (seed de dados, não DDL). |
| `20260725150000` | `0113_ai_pricing_backfill` | **Renumerada de 0068 para 0113 (2026-08-06):** nasceu com um NNNN ja usado por `0068_skills_marketplace` (`20260724120000`), e numero repetido quebra a IDENTIDADE da migration — duas linhas diferentes respondendo pelo mesmo nome. O timestamp NAO mudou, entao a `version` que o Supabase registra e a mesma e nenhum clone re-aplica; so o rotulo de rastreabilidade mudou. As strings `notes` gravadas no banco (`backfill 0068 ...`) foram DELIBERADAMENTE mantidas: sao dado historico do que rodou, nao rastreabilidade de arquivo. **Corrige custo 0 e budget sem teto em toda instalação nova.** Os seeds de `ai_pricing` existem só na 0010, mas a cadeia fresh de migrations não sobe (as 10 primeiras são stubs `SELECT 1;`) e quem instala aplica o `baseline.sql`, que semeia `ai_models` mas NÃO `ai_pricing`. Com a tabela vazia, `computeCost()` (lib/ai/cost.ts) faz lookup exato por `model` e devolve 0 sem log e sem throw → `ai_invocations.cost_cents` grava 0 e `ai_budgets` nunca acumula gasto, então o teto por organização jamais dispara (o tenant configura limite, a UI mostra, e ele não existe). FIX genérico e auto-curativo: deriva `ai_pricing` de `ai_models`, que já tem os preços do catálogo — sem hardcode, cobre modelo futuro; mais a linha do embedding `openai/text-embedding-3-small` (20¢/M, valor da 0010), que não vive em `ai_models`. Guardado por NOT EXISTS: banco que já rodava com a 0010 não muda. Prova: `computeCost('claude-sonnet-4-6', 1M, 1M)` era 0, passa a 1800¢. Sem mudança de schema → `database.types.ts` intocado. |
| `20260807010000` | `0136_demandas` | **A DEMANDA como entidade de primeira classe** (doutrina cap. 5). O propósito do sistema é resolver demandas e a unidade do propósito não existia no modelo: o objeto central era contato e conversa. Medido: `agent_cases` tinha 7 linhas e **ZERO com lead_id** — é caso de ESCALADA (nasce só de handoff, 1:1 com conversa, não conhece o negócio), bom embrião e não a unidade; e `conversations` termina quando alguém para de escrever, enquanto a demanda termina quando é RESOLVIDA — a distância entre esses dois eventos é onde as demandas morrem sem ninguém ver. Tabela `demandas` (dono NUNCA vazio; próximo passo é CAMPO e não derivação; desfecho enumerado incluindo `encerrada_pelo_cliente` e `expirada_sem_resposta`) + `demanda_conversas` N:N (uma demanda atravessa canais; uma conversa carrega mais de uma demanda). Passo 1 de 4 do cap. 5 §5.6: cria AO LADO, nada é removido. Passo 2: deriva o passado por REGRA ESCRITA (R1 casos → demandas; R2 conversas sem caso → demandas), nunca por heurística — histórico adivinhado contamina toda comparação futura. Idempotente: provado com drop+re-apply e com re-apply sobre banco populado (12 demandas nos dois). |
| `20260807050000` | `0138_demanda_no_ponto_de_entrada` | Passo 3 do cap. 5 — a demanda passa a NASCER na entrada. Sem isto, `demandas` (0119) só tinha o passado derivado e **parava de crescer**: peça que só recebe é ilha pelo invariante 1 da própria doutrina, e a entidade estaria correta e morta. Por TRIGGER e não por emissor em código porque entrada de mensagem tem mais de um caminho (ingest WAHA, rota de mensagens, seeds, canais futuros) — caçar cada emissor deixaria a garantia dependendo de alguém lembrar, e o próximo canal nasceria sem demanda em silêncio. Trigger é SQL puro sem I/O: a proibição da doutrina é *trigger fazendo HTTP* (rede dentro da transação), e `trg_messages_emit_event`/`trg_conversation_routing_requested` já usam o mesmo mecanismo nestas tabelas. Regra: inbound abre demanda **se não houver aberta para o contato** → toda entrada tem demanda, e N demandas por conversa AO LONGO DO TEMPO (fechou uma, a próxima mensagem abre outra). Deliberadamente NÃO tenta detectar dois assuntos SIMULTÂNEOS: exigiria classificação semântica, e a Fase 3 mediu que a camada lexical não separa assunto — melhor uma demanda por vez, correta, que duas adivinhadas. Fechamento junto (sem ele o denominador do índice, que conta FECHADAS, ficaria vazio para sempre) e só quando TODAS as conversas da demanda encerram — demanda que atravessou dois canais não acabou porque um fechou. Ciclo provado nos 5 casos: abre no inbound novo; NÃO duplica na 2ª mensagem; fecha com a conversa; a mensagem seguinte abre outra; outbound não abre. |
| `20260807030000` | `0137_atrito_denominador_demanda` | Spec 17 Fase 4 — o DENOMINADOR DEFINITIVO. O índice deixa de contar sobre `agent_cases` (escopo parcial, rotulado na tela como "entre as que passaram por atendimento humano") e passa a contar sobre `demandas`. Os números mudam e isso NÃO é regressão: antes media-se a fatia difícil (a que precisou de gente), agora o todo — um índice que só olha casos escalados superestima o atrito médio, e superestimar também é medir errado. Insistência, toque humano e retrabalho seguem vindo de `agent_cases` pelo ponteiro `demandas.agent_case_id`, e o payload passa a declarar `demandas_com_caso` como denominador próprio deles, para o número não ser lido como se fosse sobre o total. **O invariante 4 da doutrina vira NÚMERO**: `demandas_sem_proximo_passo` — antes da 0119 isso não era sequer enumerável. `denominador: 'demandas'` viaja no payload para quem comparar períodos saber sobre o que cada um foi medido. |
| `20260806230000` | `0135_atrito_repeticao_espera` | Spec 17, Fase 3 — REPERGUNTA (a pessoa teve de perguntar de novo: o sistema respondeu e não resolveu) e ESPERA NÃO COMUNICADA (falou e ficou sem nenhuma palavra nossa). **Jaccard de tokens em SQL nativo, não embedding**, e a razão foi medida antes de decidir: `lib/ai/embed.ts` depende de env OPCIONAL (`AI_GATEWAY_API_KEY`/OpenAI), então numa instalação self-host sem chave a métrica ficaria em ZERO **em silêncio** — e zero ali leria como "o cliente nunca precisou repetir"; e `baseline.sql` cria APENAS `pgcrypto`, então `pg_trgm` não é garantida em quem aplica só o baseline. **Limiar 0.7 CALIBRADO, não chutado**: bateria de 15 pares pt-br em três classes mostrou 0.5→3 falsos positivos, 0.6→2, 0.7→0. As faixas de repergunta (0.33–0.80) e de pergunta-diferente-sobre-o-mesmo-tema (0.17–0.67) se SOBREPÕEM — "horário aos sábados" × "aos domingos" dá 0.67 —, então não existe limiar que as separe; 0.7 é onde o falso positivo zera, ao custo de subcontar. Por isso o número é publicado como **PISO** e a tela o rotula assim. `drop function` antes do create (4→6 parâmetros; sem ele o Postgres criaria overload e a versão antiga responderia para sempre). `lag()` mantém a comparação O(n). Idempotente. |
| `20260806210000` | `0134_atrito_abandono` | Spec 17, Fase 2 — ABANDONO, o desfecho que ninguém reclama: falamos por último, a pessoa não voltou, e a demanda nunca foi encerrada. Não gera ticket, não gera nota baixa, não aparece em painel nenhum, e é a perda mais comum. `fn_atrito_metrics` ganha um 4º parâmetro `p_abandono_horas` (default 72) e devolve `abandonos` + `conversas_com_fala_nossa` (o denominador honesto: 12 abandonos não diz nada sem saber se é 12 de 15 ou 12 de 1200). A RÉGUA é parâmetro e não constante porque 72h de silêncio em WhatsApp significa outra coisa que em e-mail; ela vem de `organizations.settings->'atrito'->>'abandono_horas'`, volta no payload e é EXIBIDA na tela junto do número — cap. 3.4 regra 4 da doutrina: índice cuja definição muda sem aviso destrói a única coisa que ele tinha, que é comparabilidade no tempo. **`drop function` antes do `create`**: a assinatura muda de 3 para 4 parâmetros e, sem o drop, o Postgres criaria um OVERLOAD — a função antiga continuaria respondendo às chamadas de 3 args, em silêncio, para sempre. Mais índice parcial em `conversations (organization_id, last_outbound_at)`. Provado com régua de 72h (5 abandonos de 10) e de 1 ano (0 abandonos) sobre a MESMA fixture. Idempotente. |
| `20260806190000` | `0133_fn_atrito_metrics` | Spec 17 — o sistema não media o próprio propósito. As métricas existentes (`fn_attendant_metrics`/0037: won, lost, conversations_handled, avg_first_response_seconds) são todas de **atividade e conversão**; o propósito declarado é "menor atrito para os dois lados" e não tinha número nenhum. Consequência concreta e não hipotética: um agente que insiste seis vezes converte mais e queima relacionamento, e nos painéis atuais aparece como o melhor da organização — `agent_cases.followup_attempts` já contava a insistência e nenhuma tela lia a coluna. Adiciona `fn_atrito_metrics(org, from, to)` devolvendo os componentes deriváveis do Índice de Atrito (turnos p50/p90, insistência, pedidos de humano, descadastros, intervenções humanas por demanda, espera na fila humana p50/p90, retrabalho, vetos por execução, e envios por origem — incluindo `external_device`, que mede quantas vezes o operador respondeu pelo celular contornando a própria ferramenta). **SECURITY INVOKER de propósito**, igual à 0037: o escopo é a RLS das seis tabelas lidas, todas com `tenant_isolation` por `fn_user_org_ids()` — rodar como definer daria org-wide a quem a RLS restringe. Denominador = `agent_cases` fechados na janela: escopo PARCIAL e **rotulado na tela** (cobre demandas que passaram por caso, não o total), porque índice que finge cobrir tudo destrói a comparação quando o escopo mudar. Mais 5 índices dedicados para os predicados de janela. NNNN=0116: a 0115 já estava tomada na branch `feat/tres-papeis-do-agente` (pego pelo hook de pre-commit — número repetido quebra a IDENTIDADE da migration). Sem mudança de schema → `database.types.ts` intocado. Idempotente. |
| `20260806120000` | `0114_ai_invocations_agent_id_nullable` | Issue #160 (@jmpo): `ai_invocations.agent_id` deixa de ser NOT NULL. O classificador de sentimento roda mesmo sem agente ativo (lê o agente só para o threshold e cai no default) e auditava com `agent?.id ?? ""` — string vazia numa coluna uuid. Insert fire-and-forget, então o erro só aparecia como `warn` no log: a tabela ficava VAZIA numa instalação com 176 mensagens/24h, e as telas de consumo e custo de IA que leem dela mostravam zero enquanto o provider era pago. Das duas saídas propostas na issue, esta é a que descreve a realidade — existe invocação de IA sem agente dono; a outra trocaria bug silencioso por lacuna silenciosa. NNNN=0114 (0110–0113 ocupados). Idempotente. |
| `20260725000000` | `0070_crm_lead_owner_kind` | CRM Vivo · Wave 1 (CORE 1 — a IA é dona do negócio): `crm_leads` ganha `owner_kind ('user'\|'ai')` + `owner_agent_id uuid references ai_agents(id) on delete set null`, no padrão da 0032 (`conversations.assignee_kind`) — backfill ANTES da constraint e CHECK `crm_leads_owner_kind_coherence` em forma de implicação (drop+add, re-aplicável). FK aponta para `ai_agents` (identidade), NUNCA `ai_agent_versions`: o tooltip do card resolve "Nome · vN" por join na versão publicada no momento da exibição — congelar a versão no lead faria o card mentir depois de um republish. Índice parcial `idx_crm_leads_owner_agent (organization_id, owner_agent_id) where owner_agent_id is not null`. `fn_emit_event_on_lead_change()` re-assentada (`create or replace`, corpo da 0043 + ramo do agente): `lead.assigned` passa a disparar também quando `owner_agent_id` muda, com `from_agent_id`/`to_agent_id`/`owner_kind` no payload — sem isso a coluna nova seria ilha (nenhum consumidor lê o payload hoje, então ele só cresce). NNNN=0070 (0068/0069 tomados por `feat/harness-*`, verificado com `git ls-tree` em TODAS as branches). |
| `20260725010000` | `0071_crm_lead_activities_barramento` | CRM Vivo · Wave 3 bloco 1 (CORE 2 — **só schema**, emissores e UI vêm depois): `crm_lead_activities` vira o barramento único da vida do lead — `actor_kind ('user'\|'ai'\|'system'\|'rule'\|'contact')`, `actor_agent_id` (FK → `ai_agents`), `reason text`, `evidence jsonb`. O quinto valor é `contact` (a pessoa atendida) e **não** `lead`: deste lado da casa lead é o NEGÓCIO, e `agent` já é papel humano de RBAC. **Backfill a partir do JSONB antes da constraint**: `actor_kind`/`reason` já eram gravados dentro de `metadata` (`lib/ai/handoff/orchestrator.ts`) e teriam sido apagados por um backfill cego de `'system'`; `evidence` sobe de `metadata.run_ids`/`trace_ids`; linha marcada `'ai'` sem lastro nenhum degrada para `'system'` (preserva o registro, recusa a autoria sem prova) para o `update.sh` de clones não quebrar. Constraint `crm_lead_activities_ai_needs_evidence` com `jsonb_array_length(...) > 0` — **não** `evidence ? 'run_ids'`, que passa com array vazio. Fronteira DIRC fixada em `comment on column`: `source_module`/`source_id` = o que ORIGINOU (um ponteiro), `evidence` = o que SUSTENTA (N referências), e evidence nunca repete o source_id. De carona: `crm_leads.stage_changed_at` + trigger `trg_stamp_stage_changed_at` (sem HTTP), para o card medir tempo NO ESTÁGIO em vez de tempo sem resposta. `crm_lead_activities` entra na publicação `supabase_realtime` (o dossiê assina filtrado por `lead_id`; o board não assina esta tabela — §3.5). NNNN=0071 (0070 é meu, 0068/0069 em `feat/harness-*`; verificado com `git ls-tree` em todas as branches). |
| `20260725020000` | `0072_activity_evidence_llm_call_ids` | CRM Vivo · Wave 3 — corrige o **destino** do lastro. A 0071 definiu `evidence` como `{run_ids, trace_ids}` "no formato de `flywheel_distiller_proposals.evidence`", mas o único escritor real (o turno do agente) guardava em `run_ids` um id de **`llm_calls`** — outra tabela, não `ai_agent_runs`. O nome mentia sobre o ponteiro: quem seguisse a trilha faria join contra `ai_agent_runs` e receberia VAZIO, sem erro e sem aviso. A promessa do CORE 2 é "toda afirmação da IA tem lastro", e lastro que aponta para a tabela errada cumpre a constraint sem cumprir a promessa — agravado por `evidence` ser trilha PERMANENTE (o ensaio de LGPD provou que ela sobrevive à anonimização, por só guardar ids). A constraint `crm_lead_activities_ai_needs_evidence` passa a aceitar também `llm_call_ids`, e o `comment on column` amarra cada chave à SUA tabela (`run_ids`→`ai_agent_runs`, `trace_ids`→trace do turno, `llm_call_ids`→`llm_calls`). **Só AFROUXA** — acrescenta uma terceira forma de lastro, então nenhuma linha existente passa a violar e o `update.sh` de clone não quebra; é o oposto do risco que fez recusar uma check constraint em `type` nesta mesma wave. Feito AGORA porque havia 1 escritor e 0 linhas reais (as 2 existentes são de semente): depois seria migration MAIS correção de dados. |
| `20260725030000` | `0073_next_action_identity_and_ambiguous_inbox` | CRM Vivo · Wave 4 (emenda ao contrato). **1) `lead_state.next_action_seq`** — a trava de autorização da próxima ação comparava o **texto** da proposta, e texto igual não é proposta igual: o agente escreve "enviar proposta", o cliente muda o pedido, o agente escreve "enviar proposta" **de novo**, palavra por palavra, significando outra coisa — a trava passaria, e se o humano já tivesse ignorado a primeira, a segunda seria indistinguível dela. `updated_at` **não** serve no lugar: ele se move por estágio e por BANT (move DEMAIS), enquanto o texto move de MENOS. A regra é comparar a coisa que muda exatamente quando o que importa muda. Incrementa no caminho ÚNICO de escrita (`applyLeadStateUpdate`), inclusive quando o texto novo é idêntico — trigger não serviria, porque do lado do banco "escreveu o mesmo valor de novo" é indistinguível de "não escreveu". Default 0 nas linhas existentes: a proposta anterior a esta coluna nunca foi autorizada por ninguém. **2) kind `next_action_ambiguous` em `agent_inbox_items`** — quando o contato tem N negócios abertos a proposta não vai para card nenhum, e **não adivinhar não pode virar não avisar**: sem isto a IA propõe, ninguém vê, e a demanda apodrece em silêncio (a morte que o épico existe para matar). Kind próprio em vez de `other` porque vago não se filtra nem se conta, e este item precisa ser encontrável. **Só ACRESCENTA** um valor ao CHECK, então nenhuma linha existente passa a violar e o `update.sh` de clone não quebra. |
| `20260725040000` | `0074_lead_score_com_evidencia` | CRM Vivo · Wave 5 (CORE 3 — **só schema**, worker e UI vêm depois): `crm_leads` ganha `ai_probability numeric(5,2)`, `ai_probability_reason`, `ai_probability_evidence jsonb`, `ai_probability_at`, `ai_probability_band`, `ai_probability_band_since`. **A lei da wave mora no banco:** `crm_leads_score_needs_reason` recusa score sem razão E sem lastro — validação de aplicação morre no primeiro caminho novo que esquecer de chamar, e número sem porquê é o "dado que não muda decisão" que a doutrina proíbe (o humano não consegue nem concordar nem DISCORDAR). Constraint em forma de IMPLICAÇÃO (`score is null or (...)`), então ausência de score continua livre: é o cenário 17 — lead sem sinal suficiente não mostra score inventado, e `null` é o estado legítimo disso. Lastro contado por `jsonb_array_length(...) > 0` e **não** `evidence ? 'chave'`, que passa com array vazio (armadilha já paga na 0071). `ai_probability_band` é persistida porque **histerese precisa de memória**: sem a faixa anterior, score oscilando no limiar faz o card piscar a cada recálculo, e sinal que pisca vira enfeite — a regra de transição fica no TypeScript, a coluna é só a memória dela. Backfill ANTES da constraint apaga o SCORE órfão (não inventa razão). Provado no banco pelas duas pontas: score sem reason, com reason vazio, sem evidência e fora de 0-100 são REJEITADOS; score com razão+lastro e lead sem score PASSAM. |
| `20260725050000` | `0075_score_sai_do_lead_para_tabela_propria` | CRM Vivo · Wave 5 — **forward-fix da 0074**: os seis campos de score saem de `crm_leads` para `crm_lead_scores` (1:1, `primary key (lead_id)`, FK `on delete cascade`, RLS por `fn_user_org_ids`, **fora** da publicação `supabase_realtime`). Dois motivos com nome, ambos escritos no cabeçalho da migration porque tabela 1:1 é o que a próxima pessoa mais quer "simplificar" de volta: **o pulso que mente** (o board assina `crm_leads`, então cada recálculo em segundo plano pintaria o card como se algo tivesse acontecido) e **o 409 fantasma** (o `trg_crm_leads_updated_at` bumpa `updated_at` em qualquer escrita, e `leads/[id]/move` usa trava otimista `.eq('updated_at', ...)` — um recálculo em background INVALIDA O ARRASTO EM VOO e o usuário recebe "alguém editou este lead" sem ninguém ter editado). A composição fica certa pelo caminho existente: recálculo comum é telemetria e não toca o lead; travessia de faixa emite atividade, o `trg_update_last_activity_at` toca o lead, e aí o board pulsa pelo motivo certo. **Os seis campos migram em BLOCO** — o CHECK de coerência amarra `band` a `score` na mesma linha e CHECK não atravessa tabela; deixar "só a faixa" no lead devolveria a divergência ao terreno do improvável, que é o oposto do que o CHECK comprou. Board lê por **LEFT JOIN**: score ausente é estado legítimo (C3), e um INNER JOIN apagaria do quadro justamente os leads sem sinal suficiente — os que mais precisam de atenção humana. |
| `20260725060000` | `0076_evidencia_do_score_exige_ancora_e_fatores` | CRM Vivo · Wave 5 — **duas chaves para o mesmo jsonb, interseção VAZIA**. O CHECK guardava `activity_ids\|message_ids\|checkpoint_ids` e a tela lia `factors`: gravar só a âncora o banco ACEITA e o card diz "Sem evidências registradas"; gravar só `factors` o banco RECUSA com 23514. A lei do porquê estava sendo cobrada numa chave que a UI nunca lê, então qualquer outro escritor (backfill, script, worker futuro) satisfazia a constraint e produzia score **invisível** — banco e tela dizendo coisas opostas sobre a mesma linha, os dois certos. Passa a exigir as DUAS, que cobrem promessas diferentes do cenário 15: âncora = "clique leva ao registro", `factors` = "hover revela o porquê"; uma sem a outra dá evidência irrastreável ou ilegível. Limpeza ANTES da constraint apaga o SCORE órfão de fatores (não inventa fatores: dado plausível no lugar certo é pior que buraco). Acompanha `tests/invariants/evidencia-jsonb-chaves.test.ts`, que compara as chaves do CHECK com as que o código lê — é o anti-pattern nº 6 do CLAUDE.md, que estava proibido POR ESCRITO e aconteceu assim mesmo, porque proibição em texto não gera atrito no teclado. |
| `20260725070000` | `0077_evidencia_do_score_uma_fonte_so` | CRM Vivo · Wave 5 — **forward-fix da 0076**. Exigir âncora **e** `factors` consertava o caso (as duas passam a existir juntas) mas mantinha DUAS listas: trocava *"podem não existir juntas"* por *"existem juntas e podem DISCORDAR"* — mais raro e mais difícil de detectar, porque agora as duas passam. Conserto que transforma defeito visível em defeito silencioso é pior que não consertar. Os arrays de ids **saem** do evidence do score e a âncora passa a morar DENTRO de cada fator, que é onde a UI já a lê: com uma fonte só não há o que divergir. A promessa do cenário 15 fica cobrada por construção — `factors` não-vazio = "hover revela", `@? '$.factors[*].ancora'` = "clique leva" (jsonpath porque CHECK não aceita subconsulta, e é o que permite exigir a âncora dentro do fator). **O formato difere de `crm_lead_activities.evidence` DE PROPÓSITO**, e o `comment on column` diz isso: atividade cita FATOS de N tabelas, score cita PARCELAS de um cálculo — unificar reintroduz as duas listas. Uniformidade de forma entre coisas que significam coisas diferentes é semelhança acidental, não coerência. Limpeza antes da constraint apaga o SCORE órfão (não inventa âncora: âncora fabricada aponta para registro que não sustenta nada e é indistinguível da verdadeira). |
| `20260725080000` | `0078_estado_de_risco_do_negocio` | CRM Vivo · Wave 7 — **"esfriando" era adjetivo calculado, não estado**. `classifyRisk` é função pura recalculada a cada leitura e os únicos chamadores eram rotas de LEITURA: nenhum worker, nenhum emissor. Medido: o estado não existia até alguém abrir a tela, o vocabulário de atividades não sabia dizer "esfriou" nem "voltou" (os 6 tipos no banco não incluem risco), e — o pior — o dado não era RETIDO: sem registro de entrada e saída não há como responder "há quanto tempo está esfriando" nem "quantas vezes já esfriou e voltou". A ironia: `risk-radar.ts` se declara o desilhamento C1 da doutrina do sistema vivo, mas tornar visível numa tela que ninguém é obrigado a abrir não é anti-morte — é a mesma morte com testemunha opcional. Tabela FORA de `crm_leads` pelos dois motivos já escritos na 0075 (pulso que mente, 409 fantasma), mas **DENTRO** da publicação de realtime ao contrário de `crm_lead_scores` — e isso não contradiz a 0075, é a mesma regra dela aplicada: silêncio para telemetria, pulso para mudança de estado. `since` (quando o negócio entrou no estado) é separado de `detected_at` (quando o sistema percebeu) porque o acervo de 48 negócios já frios entra com `since` no passado; com um campo só o histórico diria para sempre que todos esfriaram no mesmo minuto. `cold_hours` grava a janela usada na decisão: sem ela, mudar `expected_duration_hours` reescreve retroativamente o significado de todo estado já gravado. |
| `20260725090000` | `0079_relogio_do_silencio_so_conta_interacao` | CRM Vivo · Wave 7 — **o produtor do estado apagava o próprio estado ao registrá-lo**. `fn_update_last_activity_at` carimbava `last_activity_at` para QUALQUER tipo de atividade, e `last_activity_at` é o relógio que decide o esfriamento: o negócio esfria, o sistema registra "esfriou", o trigger zera o relógio, e ele volta a "em dia" no mesmo instante — 24h depois de novo, uma linha de timeline por janela, para sempre, sem ninguém ter feito nada. Provado em transação revertida: 484h de silêncio (CRÍTICO) → um INSERT → 0h ("em dia"). Regra geral: **constatar o silêncio não é quebrar o silêncio** — toda métrica "tempo desde o último X" é aniquilada por registrar observação sobre ela, se o registro contar como X. O filtro é LISTA POSITIVA, não lista de exceções, e a assimetria é o ponto: com exceções, um tipo novo de observação de sistema volta a carimbar o relógio daqui a seis meses e o negócio parece vivo estando morto (morte silenciosa, a doença que a wave existe para curar); com lista positiva, um tipo novo de interação real fica de fora e o negócio parece frio estando quente (alarme falso, visível, alguém conserta). O default para o que ainda não existe tem de ser o erro barulhento. Ficam de FORA com razão declarada: `send_vetoed` (o envio não chegou ao cliente), `handoff_triggered` (passar para humano é promessa, não atendimento) e `next_action_dismissed` (decidiu NÃO agir: o negócio fica sem próximo passo, que é a definição de risco). Acompanha `tests/invariants/relogio-do-silencio.test.ts`, que mede COMPORTAMENTO tipo a tipo — conferir a lista contra si mesma passaria mesmo com o `if` invertido. |
| `20260725100000` | `0080_kind_de_caixa_para_o_acervo_de_risco` | CRM Vivo · Wave 7 — o acervo de negócios JÁ frios precisa de um destino humano. Quando o estado de risco (0078) estreia, eles entram todos de uma vez (medido: 36 críticos e 2 em risco de 56 abertos). Não podem emitir atividade de timeline — esfriaram há dias, e "esfriou agora" seria falso na única superfície que promete contar a vida do negócio — e não podem entrar em silêncio, senão ficam **absolvidos por decreto de migração**: dezenas de demandas abertas que ninguém decidiu abandonar e ninguém vai revisar. `event_log` sozinho não resolve (rastro de máquina não coloca ninguém para agir) e um item por negócio seria ruído equivalente a nenhum. Daí UM item agregado com AÇÃO NOMEADA no título — "revise os N e decida quais encerrar" é trabalho; "N negócios em risco" é um número. O CHECK ganha o kind `risk_backlog_seeded`, e o `InboxKind` de `lib/agent-engine/db/repository.ts` foi sincronizado NO MESMO COMMIT — aquela lista já ficou três valores atrás do banco sem nada falhar, e o `Record<InboxKind, string>` de `agent-inbox-copy.ts` é o portão que pegou a divergência em tempo de compilação. |
| `20260725110000` | `0081_detected_at_e_carimbo_do_banco` | CRM Vivo · Wave 7 — **dois relógios na mesma decisão**, achado rodando o observador de travessia e não por inspeção. `since` deriva de `last_activity_at`, carimbado com o `now()` do BANCO; `detected_at` vinha do processo Node. Medido: o banco está 2 SEGUNDOS à frente, e um negócio tocado no instante anterior à passada produzia `since > detected_at`, violava `crm_lead_risk_states_since_no_passado` e derrubava o worker INTEIRO. Omitir a coluna no `upsert` não resolve, e é o detalhe que engana: o default só se aplica no INSERT — no UPDATE, que é o caminho de toda travessia depois da primeira, a coluna mantém o valor ANTIGO e o `since` novo fica maior que um `detected_at` de dias atrás (pior que o caso do relógio: acontece SEMPRE). A constraint estava certa e pegou o que a inspeção não pegou; o conserto não é afrouxá-la, é tirar do cliente a chance de errar — trigger `before insert or update` carimba `detected_at` e `updated_at`. A lição é maior que a coluna: valores comparados por um CHECK têm de vir do MESMO relógio. O relógio do processo continua classificando, porque `classifyRisk` compara janelas de HORAS onde segundos não mudam bucket, enquanto o CHECK compara INSTANTES onde mudam — grandezas diferentes toleram precisões diferentes, e confundir as duas foi o defeito. |
| `20260725120000` | `0082_proposta_de_reativacao_com_prazo` | CRM Vivo · Wave 7 — a proposta de reativação, e o BLOCO OBRIGATÓRIO é recursivo: a wave existe para "esfriando" virar demanda, e a demanda que ela cria também pode morrer. Proposta que ninguém decide deixa o negócio como card parado **agora com um botão em cima** — pior que antes, porque card parado sem nada se lê como abandono e com proposta pendente SIMULA ATENÇÃO, adiando a intervenção humana em vez de provocá-la. Daí `expires_at` NOT NULL: não existe proposta sem prazo, e é o banco que garante; no vencimento ela sai do card e vira item de caixa, porque demanda sem dono não mora no Kanban. O prazo sai da JANELA DO ESTÁGIO (`coldHours`), não de constante — quem esfria em 4h decide em 4h, e uma constante daria a uma clínica o mesmo prazo de um contrato. NÃO reusa `lead_state.next_action`, e o motivo fica registrado: ela é por CONTATO (um contato com dois negócios teria uma proposta só) e é texto livre sem estado nem prazo. O ENVIO não nasce aqui — aceitar dispara o caminho que já existe (`cron_jobs` + motor de follow-up); um segundo caminho de envio seria o mesmo erro de ter duas definições de "esfriando". Índice único PARCIAL (`where status='pending'`): uma proposta viva por negócio, e as decididas ficam como histórico sem bloquear a próxima — o negócio pode esfriar de novo. `proposed_at`/`updated_at` carimbados pelo banco, pela lição da 0081. |
| `20260725130000` | `0083_kind_de_caixa_para_reativacao_vencida` | CRM Vivo · Wave 7 — a proposta vencida precisa de PARA ONDE IR. Sem o kind, o vencimento seria linha de banco e nada mais: a proposta sai do card e desaparece, o que resolve a simulação de atenção e **recria o problema anterior** (negócio parado sem ninguém sabendo). É a mesma forma da promessa cujo prazo depende de terceiro: sem fallback declarado, ela não é quebrada por decisão — expira sozinha e ninguém percebe que decidiu. O item de caixa é o fallback, com dono e ação nomeada. As duas outras pontas do CHECK (`InboxKind` e o `Record<InboxKind, string>`) foram sincronizadas no mesmo commit — e agora o invariante de vocabulário LÊ o arquivo, então esquecer não passa mais em silêncio. |
| `20260725140000` | `0084_agent_stage_hint` | CRM Vivo · Wave 8 — o funil do AGENTE aprende o vocabulário do TENANT. `lead_state.stage` tem SETE valores fixos; `crm_stages` é arbitrário por nicho (clínica: Primeiro contato/Avaliação/Proposta; e-commerce: Carrinho abandonado/Aguardando pagamento/Pago). Sem ponte, o agente avança o próprio funil e o card não se move — o board mostra um negócio parado num estágio que já não é verdade. **A ponte já existia pela metade**: `is_won`/`is_lost` mapeiam dois dos sete, então isto GENERALIZA um mecanismo incompleto em vez de criar um novo — e daí o CHECK de coerência, sem o qual `is_won` e o hint viram duas fontes capazes de discordar sobre o mesmo estágio (a família de defeito que a wave 7 encontrou seis vezes). `null` é estado legítimo: "Em separação" e "Pós-venda" não têm equivalente, e forçar mapeamento inventaria semântica que o tenant não declarou. O índice é UNIQUE seguindo o precedente exato de `uniq_crm_stages_pipeline_won`/`_lost` (parcial, excluindo arquivados): a ambiguidade de dois estágios com o mesmo hint passa a ser IMPOSSÍVEL em vez de tratada no resolvedor — e assim o tenant descobre o erro ao configurar, com o banco recusando na hora, em vez de meses depois quando um negócio não se mover. Backfill copia SÓ o que já estava decidido (`is_won`→`won`, `is_lost`→`lost`); nenhum outro estágio é adivinhado, porque inferir 'qualifying' de um nome como "Avaliação" seria decidir semântica por semelhança de palavra. |
| `20260726000000` | `0085_intent_router` | Épico Harness (F3): `ai_routers` (1 ativo por channel_session, config classifier_model/sticky/min_confidence, fallback_agent_id), `ai_router_members` (agente + intenção declarada + exemplos), `ai_router_decisions` (telemetria append-only sem PII); `conversations` ganha `active_ai_agent_id`/`active_intent`/`active_agent_set_at` (stickiness). Triggers de audit + updated_at nas editáveis. RLS `tenant_isolation_*_all`. NNNN=0085 — verificado em TODAS as refs (locais + origin): branch atual tem 0067-0069, `main` tem 0070-0084; 0085 é o primeiro livre em ambas as linhas. `database.types.ts` regenerado. |
| `20260727000000` | `0086_knowledge_searches` | Telemetria de busca de conhecimento (hits, top_score, threshold) — leitor é o Painel de Evolução da Fase 4. Sem PII. |
| `20260727120000` | `0087_channel_provider` | Seam de Canais · Task 6 — **o canal deixa de ser suposto**. Até aqui o sistema INTEIRO supunha WAHA por literal: `getAdapter("waha")` no handler de envio e `provider: 'waha'` no ctx de produção do `before_send` — os dois deixados de propósito pelas Tasks 4b/5, apontando para esta migration, porque literal é honesto enquanto a coluna não existe e vira mentira no dia em que ela existir. **Tagged union, não flag:** `provider` sozinho aceitaria uma sessão `meta_cloud` sem `meta_phone_number_id` e uma `waha` sem `waha_session_name` — as duas irresolvíveis na hora do envio, descobertas em runtime com a mensagem do cliente já aceita; `channel_sessions_provider_ref_check` move a descoberta para o INSERT. `waha_session_name` perde o NOT NULL porque ele É o identificador de um dos ramos da união (obrigatório, `meta_cloud` seria inexprimível); a UNIQUE dele continua valendo, porque NULLs são distintos entre si no Postgres — invariante testado, senão o segundo número Cloud API é que descobriria. **O que esta migration NÃO faz, e é decisão:** o plano pedia um índice único `(organization_id, phone_number)`; a trava **já existe** desde o snapshot original — `channel_sessions_phone_per_org_unique ... DEFERRABLE INITIALLY DEFERRED` — e já responde ao invariante pedido ("um número vive em UM provider"), porque não olha o provider: o par (org, número) é único, ponto. Criar o índice duplicaria a checagem em toda escrita e, pior, colocaria uma trava **não-deferível** ao lado de uma deferível, quebrando no meio qualquer transação que hoje troca números entre duas sessões (que é exatamente o motivo de alguém tê-la feito DEFERRABLE). **Backfill: nenhum, por construção e não por sorte** — o default preenche as linhas existentes no mesmo ALTER e `waha_session_name` era NOT NULL até aqui, então TODA linha pré-existente satisfaz o ramo 'waha' antes do CHECK nascer; vale para qualquer clone, não só para este banco, e é o que garante o `update.sh`. Acompanha `tests/invariants/channel-provider-schema.test.ts` (comportamento, não nome de constraint: os casos centrais são INSERTs que TÊM de estourar, com o nome da trava no erro — sem o nome, "rejeitou" não distingue o CHECK certo de um NOT NULL, de uma FK ou da RLS) e o pareamento do CHECK com `lib/channels/types.ts → ChannelProvider` — que ficou em `channel-provider-schema.test.ts` (o caso "o vocabulário do CHECK é o mesmo do ChannelProvider"), **não** como linha em `vocabulario-banco-x-typescript.test.ts`: aquele arquivo pareia coluna com símbolo por um extrator genérico, e este par precisa de extrator próprio porque o vocabulário do provider vive numa union de tipo, não num array de valores. Este registro dizia o arquivo errado. |
| `20260728120000` | `0088_meta_templates` | Seam de Canais · Fase 3a Task 3 — **o template da Meta ganha espelho local**. O template VIVE NA META; sem cópia local a tela precisaria de uma ida à Graph API por render, e o `contract_hash` (âncora da trava por obsolescência da Task 6) não teria onde ser comparado entre um sync e o próximo. O espelho é derivado, nunca autoritativo: toda coluna vem do que a Meta devolveu e `contract_hash` é calculado de `components` por `lib/channels/meta/contract-hash.ts`, nunca redigitado — Global Constraint nº 1 do plano ("nada de redigitar contrato"). **A chave é `(organization_id, waba_id, name, language)`, nunca só `name`:** `pedido_confirmado` em `pt_BR` e em `pt` são DOIS templates distintos na Meta, com aprovação, status e corpo independentes; chave só por nome os colapsaria na mesma linha e o envio escolheria o idioma por sorte de qual sincronizou por último. **`status` fica deliberadamente SEM CHECK** — vocabulário ABERTO cujo dono é a Meta (hoje APPROVED/PENDING/REJECTED/PAUSED/DISABLED/IN_APPEAL/PENDING_DELETION, amanhã o que ela inventar): CHECK viraria `23514` no meio do sync e quebraria o `update.sh` do clone que já gravou o valor legado, o que a doutrina de migrations proíbe. Mesmo tratamento de `crm_lead_activities.type`: o vocabulário vive no TypeScript (`lib/channels/meta/template-sync.ts` → `MetaTemplateStatus`), o emissor usa a constante compartilhada, e a coluna fica **fora** do invariante `vocabulario-banco-x-typescript.test.ts`, que cobre apenas colunas que JÁ têm CHECK. `parameter_format` tem default `'POSITIONAL'` porque é o que a Meta assume quando o campo não vem — e **medido contra a WABA real, ele só vem quando pedido explicitamente nos `fields`**; default errado montaria payload NAMED como POSITIONAL, que é o 132012 pela porta dos fundos. **Backfill: nenhum, por construção** — a tabela nasce vazia, não há dado a deduplicar antes do índice único, e todo statement do apêndice é `if not exists` / `drop policy if exists`+`create policy`, então o `update.sh` de qualquer clone o re-aplica sem efeito. Acompanha `tests/invariants/meta-templates-rls.test.ts` (isolamento por org nas duas direções, `with check` barrando escrita cross-org, e a unicidade por idioma provada pelos dois lados: mesmo par colide, idioma diferente convive). |
| `20260728130000` | `0089_system_self_update` | Tabelas `system_version` (singleton) e `system_update_runs`: estado da atualização self-service disparada pela UI. Sem `organization_id` (instância, não inquilino) e sem policy de RLS — acesso só via service role nas rotas `/api/v1/system/*`. |
| `20260728140000` | `0090_system_update_dispatched_unique` | Fix da Task 4 (review round 1): índice único parcial `uniq_system_update_runs_dispatched` em `system_update_runs (status) where status = 'dispatched'` — torna real o invariante "no máximo 1 run dispatched por vez" que a rota `/api/v1/system/agent` passou a assumir (antes era só expectativa de aplicação, um TOCTOU sob concorrência). Dedup defensivo antes da constraint. |
| `20260729120000` | `0091_message_type_template` | Seam de Canais · Fase 4 — **`template` vira tipo de mensagem de primeira classe**, mais `messages.template_name`/`template_language`. Gravar template como `type:'text'` com o corpo renderizado compilaria e seria **mentira no banco**: o tipo e a unica coluna que carrega (a) **custo** — template e cobrado por entrega desde 01/07/2025, texto livre dentro da janela e gratis, e sem o tipo ninguem soma a fatura pelo historico; (b) **conformidade** — fora da janela de 24h so template e permitido, e auditoria que pergunte 'esse envio respeitou a janela?' precisa do tipo; (c) **o que o contato viu** — template tem cabecalho, rodape e botoes que o corpo renderizado nao carrega. CHECK ALTERADO, nao removido: `messages.type` e vocabulario FECHADO (quem escreve e o nosso codigo), diferente de `meta_templates.status` (a Meta inventa estado) e `crm_lead_activities.type` (clone pode ter valor legado) — aqui o CHECK se paga movendo o erro para o INSERT. `template_name` em COLUNA, nao em `metadata`: e a chave de custo e auditoria, e `metadata` e vocabulario aberto por desenho, o que tornaria a consulta uma aposta. **Backfill: nenhum por construcao** — a migration so ACRESCENTA valor ao conjunto, entao toda linha existente ja satisfaz o CHECK novo em qualquer clone. NNNN=0091 medido em TODAS as branches locais (0089 e 0090 ocupados). |
| `20260729000000` | `0092_stage_names_acentos` | Acentos nas etapas padrão do funil: o seed criava "Em separacao" e "Pos-venda" sem acento, e esses nomes aparecem no quadro principal. Corrige o seed e cura instalações existentes (só onde o nome padrão está intacto). |
| `20260729130000` | `0093_system_version_compare_failed` | Coluna `system_version.compare_failed`: o agente do host passa a dizer explicitamente quando NÃO conseguiu comparar a versão instalada com a última publicada (clone raso que não completou a história). Sem ela, a ausência de versão nova era lida como "você está em dia" — uma instalação atrasada era informada de que estava atualizada, em silêncio. |
| `20260730000000` | `0094_system_version_has_known_release` | Coluna `system_version.has_known_release`: distingue "instalação à frente da versão publicada" (existe tag, já contida no HEAD) de "nunca houve versão publicada" (fork sem nenhuma tag `v*`) — as duas produziam a mesma combinação `off_release=true, latest_version="", compare_failed=false`, e a tela afirmava "à frente da publicada" sem versão nenhuma existir. |
| `20260730180000` | `0095_budget_conta_llm_calls` | O gatilho de consumo do orçamento de IA existia só em `ai_invocations` (workers legados); o agent-engine grava em `llm_calls`, então o contador ficava zerado, a tela mostrava R$ 0,00 com dinheiro saindo e o alarme/pausa nunca disparavam. Passa a valer nas duas + reconcilia o mês corrente. |
| `20260730200000` | `0096_llm_default_model_da_org` | Toda org nasce (e as existentes são curadas) com `settings.llm.default_model`. Sem ele o caminho genérico do turno — documentado como "não é silêncio" — ficava sem modelo e o turno morria com "modelo LLM não definido"; bastava um roteador sem membros para derrubar TODAS as respostas. |
| `20260730220000` | `0097_rag_threshold_calibrado` | Limiar de similaridade do RAG de 0.72 para 0.40, calibrado por medição (relevante 0.49–0.85, irrelevante 0.27). Com 0.72 só a pergunta literal do FAQ passava e toda paráfrase era descartada. |
| `20260801200000` | `0098_contacts_locale` | Adiciona `contacts.locale` (nullable). O `ai-response-worker` já selecionava a coluna e o prompt já usava `{{contact_locale}}`, mas ela nunca existiu no baseline: em toda instalação self-host o PostgREST devolvia "column contacts_1.locale does not exist" e o worker pulava TODA conversa, com o erro escondido num log de nível info. NULL = herda o padrão da organização (fallback pt-BR no código); sem CHECK porque locale é vocabulário aberto. |
| `20260801220000` | `0099_contacts_avatar` | Adiciona `contacts.avatar_storage_path` e `avatar_updated_at` (+ índice parcial p/ o cron de refresh). A foto de perfil vem do WAHA como URL assinada do CDN do WhatsApp que EXPIRA — medido: 9 dias. Guardar a URL faria todo avatar quebrar em silêncio; por isso o arquivo vai para o bucket `whatsapp-media` e a coluna guarda o caminho, como `messages.media_storage_path`. É também o que torna a remoção na anonimização LGPD garantível. O índice é parcial em `(avatar_updated_at nulls first) where wa_identity is not null and is_anonymized = false`: a varredura do cron não filtra organização, então um composto liderado por `organization_id` nunca seria usado — medido em pg17 com 20.000 contatos, 10,272 ms (seq scan) → 0,090 ms (index scan). |
| `20260804210000` | `0101_autoria_da_configuracao` | Adiciona `last_change_actor_kind` (`user`|`ai`|`system`, com CHECK) e `last_change_at` em `crm_stages`, `webhook_sources` e `automation_rules`. **Por quê:** até o épico IA 360, toda mudança de configuração da operação vinha de uma pessoa `manager+` — quem olhava a tela era, por construção, quem tinha mudado. Com o agente ligando uma regra que roda sozinha, a tela mostrava "Ativa" e não dizia mais nada; o `api_audit_log` registrava, mas nenhuma tela de configuração o lê (log invisível é log morto, doutrina do sistema vivo §3). A autoria mora na PRÓPRIA LINHA para ser lida na mesma consulta que o estado. **Sem coluna de qual agente, deliberadamente:** `Actor.id` para `ai_agent` ainda não é chave estável de agente nos três caminhos — `lib/mcp/auth.ts` devolve o id do RUN (ou do token) no caminho do cliente MCP externo —, então uma FK para `ai_agents(id)` recusaria a escrita com 23503 justamente ali. Nullable e sem backfill: linha antiga fica com autoria desconhecida, que é a verdade sobre ela. |
| `20260804220000` | `0103_uso_de_capacidades_do_agente` | Cria `fn_agent_tool_usage(org, agent, since)`. Toda chamada de tool do agente já era auditada em `api_audit_log` (`action='mcp.tool_called'`) desde a Spec 11 e **nenhuma tela lia** — um grep no repo por `mcp.tool_called` só achava o emissor. Log invisível é log morto (invariante 3 da doutrina do sistema vivo): quem liga uma capacidade não sabia se ela é usada, se falha, ou se só ocupa uma das 20 vagas. A agregação vive no banco porque não há FK entre `api_audit_log` e `ai_agent_runs` (audit é append-only e genérico), e amarrá-los no Node exigiria devolver os ids de ~9.000 runs mensais de um tenant PME num `in(...)`. O elo é `api_audit_log.request_id = ai_agent_runs.id` (o runtime usa o id do run como `requestId` do `McpContext`). A janela filtra `r.started_at` para cair na coluna líder de `ai_agent_runs_agent_idx`; `idx_audit_request` resolve as chamadas de cada run. `em_teste` separa `is_dry_run`, senão a tela diria "usada 4 vezes" quando as 4 foram o dono clicando em Testar. `security invoker`: pelo service role a RLS não se aplica (a rota já resolve a org do cookie), por usuário autenticado `audit_log_select` continua exigindo admin. |
| `20260804180000` | `0102_cron_jobs_retorno_cancelado` | Adiciona `cron_jobs.cancelled_at` e `cancel_reason` (+ índice parcial do retorno vivo por contato). `enabled = false` significava DUAS coisas — o one-shot disparou ou alguém desmarcou — e enquanto forem indistinguíveis o agente não sabe, ao retomar, que o humano cancelou o retorno: o invariante 2 da doutrina (continuidade humano→IA) fica pela metade, e a fila mostra "concluída" para um retorno que ninguém executou. Sem backfill de propósito: não se sabe quais linhas antigas foram canceladas antes da coluna existir, e chutar seria gravar ficção em histórico. |
| `20260804200000` | `0100_agent_case_events_agent_noted` | Acrescenta `agent_noted` ao CHECK de `agent_case_events.kind`. O agente conseguia ABRIR um chamado e nada mais: não havia como registrar o que aconteceu depois, e nenhum valor existente do CHECK servia sem mentir sobre o autor (`lead_provided` é a informação que o LEAD deu, `human_replied` é a pessoa). Sem esse registro, o atendente seguinte que abre o chamado começa do zero — a descontinuidade que o pacote de escalação existe para fechar. A lista só cresce, então nenhuma linha existente viola a constraint nova e não há backfill antes de criá-la. |
| `20260805120000` | `0104_catalogo_de_modelos_atualizado` | Atualiza `ai_models` e `ai_pricing` para a geração corrente dos três provedores. O catálogo curado estava duas gerações atrás (padrão OpenAI = `gpt-5-mini`, Anthropic = `claude-sonnet-4-6`), e é por ele que quem instala numa VPS escolhe modelo: catálogo velho é o cliente pagando mais caro por um modelo pior sem saber que existe melhor. Acrescenta Opus 5 / Sonnet 5 / Opus 4.8, a linha gpt-5.6 e gpt-5.4/5.5, e Gemini 3.5 Flash / 3.1 Pro (Preview) / 2.5 Flash-Lite / 2.0 Flash; move o padrão de cada provedor para o nível intermediário atual. **Corrige preço errado que já estava lá**: a saída do `gemini-2.5-pro` é $10 (estava $5) e a do `gemini-2.5-flash` é $2,50 (estava $1,20) por milhão — preço errado no catálogo vira orçamento errado na tela. Os ids de Anthropic e OpenAI foram VERIFICADOS no provedor (`GET /v1/models`); os do Google **não** (sem chave nesta máquina) e estão declarados como tal no cabeçalho. `context_window` e `released_at` ficam NULL nos modelos novos de propósito — o dado não estava disponível, e número inventado numa coluna que a tela mostra é pior que coluna vazia. |
| `20260805140000` | `0105_inbox_capabilities_missing` | Acrescenta `capabilities_missing` ao CHECK de `agent_inbox_items.kind`. Quando o turno não consegue montar as capacidades que o humano ligou na tela, ele segue sem elas — o que está certo, a conversa do cliente não pode morrer por causa de uma tool extra. O errado era o depois: o código afirmava em comentário que "o humano vê o log", e não vê — o log vai para o stdout do worker, num contêiner de VPS que o dono do negócio nunca abre. Medido num turno real: o agente atendeu sem NENHUMA capacidade configurada e a única pista existia num log que ninguém lê. É falha-em-verde: anunciada na tela, ausente na execução, e nada contando. O kind próprio (em vez de `other`) existe porque a Central de avisos agrupa e explica por kind — `other` caberia no CHECK e mentiria na tela. |
| `20260805150000` | `0106_channel_sessions_archived_at` | Adiciona `channel_sessions.archived_at` (+ índice parcial `where archived_at is null`). A Central de Conexões não tinha como excluir um canal, e um DELETE puro é impossível: `conversations`, `messages` e `ai_agent_versions` referenciam `channel_sessions` com ON DELETE RESTRICT — o Postgres recusa apagar um canal que já atendeu alguém, e forçar significaria destruir o histórico de atendimento junto. Canal virgem passa a ser apagado de verdade; canal com histórico é arquivado, some da UI e das listagens, e a linha sobrevive como âncora das FKs. O índice parcial cobre o filtro `archived_at IS NULL` que agora existe em toda listagem de canais. |
| `20260805160000` | `0107_channel_sessions_phone_unique_ativos` | `channel_sessions_phone_per_org_unique` deixa de ser UNIQUE constraint e vira **índice único PARCIAL com o mesmo nome**, `where archived_at is null`. A trava é do snapshot original e não conhece arquivamento (0100): a linha arquivada seguia ocupando o par (organização, número) para sempre, então reparear o MESMO número — que é exatamente o que o diálogo de exclusão promete ser possível — fazia a primeira gravação do telefone na linha NOVA colidir com a ARQUIVADA (23505), e o canal novo ficava sem número. O invariante protegido é "um número vive em UM canal ATIVO", e canal arquivado não é ativo. **O nome é preservado de propósito**: `tests/invariants/channel-provider-schema.test.ts` cobra o nome da trava dentro da mensagem de erro do INSERT recusado, e mantendo o nome ele continua cobrando o mesmo comportamento entre dois canais ativos. **Perde o `DEFERRABLE INITIALLY DEFERRED`** (índice parcial não pode ser deferível): o registro de 0087 justificava não tocar nela citando "qualquer transação que hoje troca números entre duas sessões" — medido nesta árvore, essa transação NÃO existe; os únicos escritores de `channel_sessions.phone_number` são o health check (`app/api/v1/channel-sessions/[id]/route.ts`) e a conexão do canal oficial (`app/api/v1/channels/official/route.ts`), cada um com UM update/insert de UMA linha via PostgREST. **Backfill: nenhum, por construção** — a constraint antiga é estritamente mais forte que o índice novo (todas as linhas vs. um subconjunto), então nenhum banco que a satisfazia pode violá-lo. |
| `20260805180000` | `0108_revoke_definer_exposto` | Issue #128: `security definer` de `public` exposta a `anon`. Medido no baseline da main em Postgres 17 descartável: **8 das 25** tinham `EXECUTE` para `anon`, incluindo `fn_publish_ai_agent_version`, que ESCREVE e recebe o org por argumento sem checar membership (RPC do PostgREST alcançável com a anon key do browser). São DUAS origens de EXECUTE e cada uma pede um revoke próprio: (A) grant direto a `anon` do `ALTER DEFAULT PRIVILEGES` do baseline, que `revoke from public` não remove; (B) grant a `PUBLIC` que o Postgres dá a toda função ao criá-la, que `revoke from anon` não remove — as 8 estavam expostas por (B). Daí `from public, anon` em todas, + re-grant explícito. Também revoga `authenticated` de 5 definer VOLÁTEIS cujo único call site usa o client de service role (`fn_upsert_wa_contact`, `fn_upsert_wa_conversation`, `fn_mark_conversation_message`, `fn_publish_ai_agent_version`, `activate_kb_version`) — o grant permitia escrita cross-tenant por qualquer usuário logado. Vigiado por varredura genérica em `tests/invariants/hardening-definer-varredura.test.ts` (substitui a lista fixa de 6). Idempotente/auto-curativo. |
| `20260805190000` | `0109_inbox_kind_message_send_stuck` | Issue #129: `agent_inbox_items.kind` ganha `message_send_stuck`. Mensagem outbound nasce `status='sending'` e, quando o envio nunca acontece, fica `sending` para sempre — o self-hoster vê uma mensagem eternamente "enviando", sinal de progresso para algo que não vai acontecer, e numa VPS não há ninguém monitorando para notar. O cron `recover-stuck-messages` (rota nova) marca `failed` e usa este kind para o defeito APARECER na Central de avisos, em vez de só no log do worker. NNNN=0109 porque a 0108 é da issue #128 (branch irmã). Idempotente: a lista de kinds só cresce. |
| `20260805200000` | `0110_lead_checkpoints_declaracao` | Spec 16 §5: `lead_checkpoints` ganha `declaracao jsonb` — a fronteira DECLARADA entre FALAR e OPERAR. O Conversador fecha o turno dizendo, em linguagem de negócio, o que a pessoa quer (`intencoes`) e o que foi prometido a ela (`promessas`); nenhum nome de ferramenta, id ou estágio entra, porque o vocabulário no contexto de quem fala é o defeito medido (30% de vazamento com prompt de operador). Viaja na chamada de fechamento que já existe — imposta pelo runtime, custo zero — em vez de numa tool, que o modelo esqueceria justamente no turno que importa. **NULLABLE de propósito:** NULL = não declarou; `{"nada_a_declarar":true}` = avaliou e não havia nada. São estados distintos, e um `not null default` colapsaria os dois, escondendo o esquecimento que o invariante 4 manda mostrar. Sem CHECK de shape — a validação real é o Zod `.strict()` de `lib/agent-engine/agent/declaracao.ts`. Aditiva e idempotente. |
| `20260806100000` | `0111_operator_turn` | Spec 16 §3.2: nasce o papel OPERADOR — `job_queue.kind` aceita `operator_turn`, e `ai_agent_versions` ganha `operator_enabled` (default **false**) + `operator_model` (NULL = herda). O disparo é imposto pelo runtime ao fim do turno do Conversador, nunca por decisão do modelo: um Conversador que 'chama' o Operador devolve o problema inteiro — volta a depender de o modelo lembrar, e o turno em que ele não achasse necessário seria um lead parado no funil, em silêncio. **São DOIS CHECKs e o segundo é o que quebra se esquecido:** `job_queue` exige coerência entre kind e `contact_id`, e `operator_turn` TEM contato — sem estender essa lista todo insert viola a constraint e o Operador nunca roda (o nome dela é anônimo em bancos antigos, daí a busca no catálogo). `operator_enabled` nasce false porque migration não liga sozinha um papel que gasta uma chamada de modelo por turno na chave do self-hoster. Aditiva e idempotente. |
| `20260806140000` | `0112_operator_tool_ids` | Spec 16 §6: `ai_agent_versions.operator_tool_ids` — as capacidades do papel OPERADOR, em coluna PRÓPRIA e não reusando `tool_ids`. Três razões que se somam: (1) **a tela não pode mentir** — lista compartilhada faria a seção 'Operador' configurar o que o Conversador executa, ensinando um modelo mental que o motor não implementa; (2) **o teto de 20 se resolve por divisão, não por aumento** — hoje o Conversador carrega até 12 nativas + 20 de catálogo = 32 num prompt só, e o e2e `capacidades-do-agente` está fora do CI porque ligar 'Atender' estoura o teto; (3) **o passo 6 vira migration de DADOS** — tirar as ferramentas de escrita do Conversador passa a ser mover ids entre colunas, com rollback trivial. Default `'{}'`: o papel nasce sem mão, porque herdar as do Conversador em silêncio daria 20 capacidades a quem não escolheu nenhuma. Aditiva e idempotente. |
| `20260806180000` | `0115_deletar_org_e_agente` | Duas entidades que **não se conseguia apagar**, achadas ao remover as fixtures de E2E da produção. Mesma família: uma escrita AUTOMÁTICA reagindo ao DELETE e violando regra que vale para o estado normal, não para a remoção. **(1)** Apagar uma ORGANIZAÇÃO falhava — o cascade apaga os filhos e o trigger de audit de cada um insere em `api_audit_log` com o `organization_id` de uma org que já não existe (`api_audit_log_organization_id_fkey`). Agora o audit é pulado no DELETE quando a org já sumiu; nada se perde, porque essa linha seria apagada pelo cascade em seguida. A checagem fica SÓ no ramo DELETE — um `exists` no INSERT/UPDATE cobraria um SELECT em todo hot path de escrita. **(2)** Apagar um AGENTE que já atendeu falhava — `crm_leads_owner_agent_id_fkey` é ON DELETE SET NULL e o CHECK `crm_leads_owner_kind_coherence` exige agente quando `owner_kind='ai'`; o SET NULL zerava um lado e deixava o outro. Agora um BEFORE DELETE desfaz a atribuição INTEIRA (os dois campos) antes de a FK agir. O CHECK **não** foi afrouxado: ele descreve invariante verdadeiro, e tolerar `'ai'` sem agente trocaria erro barulhento por dado incoerente em silêncio. Idempotente (`create or replace` + `drop trigger if exists`). |
| `20260808020000` | `0131_canal_zernio_vocabulario` | Vocabulário de um TERCEIRO canal (BSP intermediário) em `channel_sessions`, **antes** do transporte: o tipo `ChannelProvider`, a matriz de capabilities e a coluna de ref nascem juntos, e o adapter chega depois encontrando o schema pronto — o caminho inverso obriga a migration de correção sobre dados existentes, que é onde clone quebra. Coluna NOVA `zernio_account_id` e não reuso de `meta_phone_number_id`: o identificador é o `accountId` do INTERMEDIÁRIO, não o `phone_number_id` da Meta — espaços de id diferentes, e reusar a coluna faria o nome mentir sobre metade das linhas. Os dois CHECKs (`_provider_check` e `_provider_ref_check`) são **recriados** (drop + add) em vez de criados com `exception when duplicate_object`: num clone eles JÁ EXISTEM na versão de dois providers, e o `duplicate_object` engoliria a versão nova em silêncio — `update.sh` verde e o canal recusado pelo banco, a falha-em-verde que a doutrina do self-host proíbe. Aditiva e auto-curativa: coluna nullable, e toda linha pré-existente é `waha` ou `meta_cloud` e já satisfaz seu ramo — nada a deduplicar antes das constraints. |
| `20260808030000` | `0132_zernio_envio` | O que falta para o terceiro canal ENVIAR. **`conversations.provider_conversation_id`**: os dois canais existentes DERIVAM o destinatário do contato (WAHA monta o chatId do telefone; o oficial usa E.164 em dígitos). Este não — quem endereça é um id de 24 hex que o INTERMEDIÁRIO inventa e devolve pelo webhook `message.received` (medido contra a API real: `6a76a2dc4b8fe115e5f6c300` ← participante `595981233187`). Sem guardá-lo não há como responder **dentro** da janela de 24h: o endpoint que aceita telefone exige template e devolve `TEMPLATE_REQUIRED`, que é o caminho errado para uma resposta de atendimento. Nome **genérico** e não `zernio_conversation_id`: é o mesmo conceito para qualquer provider que enderece por thread própria; `channel_sessions` usa colunas por provider porque lá o FORMATO difere e o CHECK precisa saber qual exigir, aqui o conceito é um só. Índice PARCIAL (`where ... is not null`) porque a busca é sempre "achar a conversa deste id" na entrada do webhook, e as linhas dos outros dois canais não pagam por ele. **`channel_sessions.zernio_token_encrypted`**: credencial por SESSÃO e não por instalação — mesma decisão da 0087, mesmo motivo (duas orgs com contas diferentes na mesma instalação), cifrada pelas MESMAS RPCs `fn_encrypt_oauth`/`fn_decrypt_oauth`; um terceiro caminho de cifra seria mais um lugar por onde a chave vaza. Aditiva e idempotente: as duas colunas nascem nullable, sem constraint nova, nada a corrigir antes. |
| `20260807120000` | `0126_ai_purpose_bindings` | Onde a escolha de modelo de **cada ponto que usa IA** passa a morar. O sistema chama modelo em 23 lugares (catalogados em `lib/ai/pontos/registro.ts`) e a escolha estava espalhada por três pilhas que não se falam — `runModelCall` com BYOK por org, `lib/ai/gateway.ts` por env e um terceiro `switch` em `lib/ai/runtime/agent.ts` — mais sete variáveis de ambiente. Sem superfície para configurar, o operador não conseguia nem descobrir quem usava o quê, nem corrigir o ponto que falhava; as falhas mudas medidas (chave da Anthropic enviada ao Whisper, `gpt-5-mini` no endpoint da Anthropic, visão de imagem devolvendo vazio) têm todas essa mesma raiz. Uma linha por `(organization_id, purpose)`; **ausência de linha = comportamento anterior preservado**, então a migration não altera nenhuma instalação existente. `provider` **sem CHECK de propósito**: é vocabulário aberto, e os três CHECKs de provider que já existem são justamente o que trava a entrada da OpenRouter — um quarto repetiria o erro; quem recusa provider desconhecido é o registry, com erro tipado. `base_url` nasce sem consumidor imediato porque é o que permite apontar um ponto para endpoint compatível com a API da OpenAI (OpenRouter agora, modelo local no roteiro) — sem ele, outra migration de tabela depois. `credential_id` é ON DELETE CASCADE: apagar a chave apaga o binding e o ponto volta ao padrão, em vez de apontar para credencial inexistente e falhar em toda chamada. RLS por organização. Aditiva e idempotente; o apêndice do baseline deduplica antes de criar a constraint única, para o `update.sh` de um clone com estado intermediário não morrer em silêncio. |
| `20260807140000` | `0127_provider_vocabulario_aberto` | **O que destravava a OpenRouter.** Três CHECKs prendiam `provider` em `anthropic|openai|google` (`ai_agent_versions`, `ai_models`, `ai_provider_credentials`); enquanto existissem, cadastrar chave da OpenRouter era impossível — o INSERT viola constraint antes de qualquer código rodar, e o mesmo valeria para o próximo provedor e para modelo local, que é o roteiro declarado. **Removidos, não estendidos:** quatro valores comprariam o mesmo problema adiante (cada provedor novo viraria migration, e o clone que não a aplicasse recusaria a chave sem a tela saber explicar). É o caso de vocabulário ABERTO que a doutrina já prevê — o vocabulário vive no TypeScript e quem recusa provider desconhecido é o registry, com `LlmProviderUnknownError`, não uma violação de constraint. O CHECK nunca foi a defesa real: quem executa é `createDefaultRegistry()`, que já falhava ali. Entram CHECKs de **não-vazio** no lugar (aberto não é livre: string vazia seria linha que nenhum registry resolve e nenhuma tela exibe). `ai_models` ganha `source`, `synced_at` e `supports_vision` para o sincronizador de catálogo: ele só toca nas linhas da própria origem (apagar o que um humano cadastrou seria perder configuração em silêncio) e marca `deprecated_at` no que sumiu, nunca DELETE — a linha ainda é referenciada pelo histórico de custo. Índice único `(provider, model_id)` para o upsert, com **dedup antes** no apêndice do baseline, porque a garantia nunca existiu e o `update.sh` roda sem `ON_ERROR_STOP`: índice que falha é pulado em silêncio e o upsert volta a duplicar. |
| `20260807160500` | `0128_llm_calls_registra_falha` | **O log de IA passa a registrar o que deu errado.** `llm_calls` gravava uma linha por chamada e só quando dava certo — o INSERT vive depois do `generateText` em `run-model-call.ts`, sem `try` em volta; provedor recusando a chave, modelo inexistente ou conta sem saldo faziam a exceção subir sem deixar rastro. É a causa direta de "o agente não responde e não aparece erro em lugar nenhum": a tabela que deveria explicar era justamente a que ficava vazia no caso que precisa de explicação, e o painel de uso mostrava o mês inteiro sem uma linha vermelha. Entram `status` (default `'ok'`, então toda linha histórica — todas bem-sucedidas por construção — segue correta sem backfill), `error_code` normalizado, `error_message` **truncada e sem prompt/resposta/chave** (erro de provedor às vezes ecoa o corpo da requisição, e conteúdo de mensagem é PII aqui), `http_status` (separa credencial de limite e de indisponibilidade — três conversas diferentes com quem instalou) e `origem_da_escolha`, que é o campo que transforma o log de "o que aconteceu" em "por que aconteceu". Dois índices parciais para as duas leituras da tela de execuções. Aditiva e idempotente; o CHECK de `status` é criado depois de normalizar os dados, porque o `update.sh` roda sem `ON_ERROR_STOP` e uma constraint que falhasse seria pulada em silêncio. |
| `20260807180000` | `0129_midia_nao_lida` | **A midia que o agente nao consegue ler passa a aparecer.** O cliente mandava foto ou audio e o agente respondia como se nada tivesse chegado — a derivacao devolvia string vazia EM SILENCIO em dois casos reais: modelo configurado que nao enxerga imagem, e falta da chave da OpenAI para transcrever (o Whisper e da OpenAI mesmo com o resto da organizacao em outro provedor). Sem erro, sem log, sem aviso: para quem instalou, o produto parecia ignorar o cliente de proposito, e o caminho de diagnostico nao existia porque nada tinha acontecido do ponto de vista do sistema. A migration so acrescenta o `kind`; o comportamento e codigo — marcador no texto derivado (o AGENTE passa a saber que recebeu algo que nao interpretou, e responde pedindo em texto, em vez de silenciar) mais um item na Central (o OPERADOR ganha o que fazer). Um sem o outro nao resolve. O kind entra na MESMA constraint, nao num bloco novo — reconstruir a mesma constraint em N blocos foi o defeito da issue #159. |
| `20260807200000` | `0130_unifica_ai_invocations` | **Uma tabela de telemetria de IA, nao duas.** `ai_invocations` (workers legados) e `llm_calls` (agent-engine) contavam a mesma coisa em lugares diferentes; a tela de uso lia so a primeira e mostrava ZERO custo enquanto o dinheiro saia — medido numa VPS, 90 chamadas e R$ 0,15 gastos com a tela em branco. O remendo foi a API somar as duas, o que conserta a tela e deixa a raiz: toda leitura nova de telemetria precisa lembrar das duas, e a que esquecer mente. Aqui `llm_calls` vira a unica e `ai_invocations` fica como historico — **nao e apagada** (a doutrina e depreciar, nao deletar, e as linhas antigas sao a prova do que foi gasto). `legacy_invocation_id` com indice unico e o que torna o backfill idempotente: o `update.sh` re-aplica o baseline a cada atualizacao e, sem a marca, o custo do mes passado cresceria sozinho a cada execucao — erro que ninguem ve acontecer e que aparece como "a conta subiu" sem nenhuma chamada nova. O `provider`, que `ai_invocations` nao guardava, e derivado do prefixo do modelo e vira `'desconhecido'` quando nao da para saber, em vez de um chute que viraria estatistica. |
| `20260808060000` | `0141_binding_sobrevive_a_rotacao` | **Rotacionar a chave apagava a configuração dos pontos.** `ai_purpose_bindings.credential_id` nasceu `on delete cascade`: apagar uma credencial não desvinculava, **removia a linha inteira do binding** — provider, model_id, base_url e is_enabled junto. O caminho é banal e recomendado (rotação: apaga a antiga, cadastra a nova) e o desfecho é mudo: todos os pontos que apontavam para ela voltam ao padrão da organização e a tela passa a dizer "Usando o padrão da organização" — frase verdadeira sobre um estado que ninguém escolheu. `set null` é o desfecho certo porque `credential_id is null` JÁ significa "use a chave da instalação" no produto, e é o valor com que todo binding nasce: o ponto continua no provedor e no modelo escolhidos. `restrict` foi recusado — transformaria "apagar chave" numa operação que falha com erro de FK, e a tela de Credenciais não mostra quais pontos usam cada chave para o operador desfazer antes. Idempotente: drop pelo nome + add. |
| `20260808050000` | `0140_orcamento_nao_conta_backfill` | **O `update.sh` inventava gasto de IA.** `fn_update_budget_consumption` é um trigger AFTER INSERT que soma `NEW.cost_cents` a `ai_budgets.current_month_consumed_cents` **sem olhar a data da linha** — correto para gasto novo, onde "agora" é sempre o mês corrente. A 0095 pendurou esse trigger em `llm_calls`; a 0130 fez o backfill de `ai_invocations` para dentro de `llm_calls`. O backfill é um INSERT: cada linha migrada disparou o trigger, **inclusive as de meses passados**, e o recomputo da 0095 somava as duas tabelas inteiras, contando a mesma linha nas duas pontas. Medido em pg17 (gasto real do mês 1600): `0 → 3000` depois de UM `update.sh`, estabilizando em `2600`, nunca em 1600; numa organização sem gasto no mês, `0 → histórico inteiro` = 200% do `monthly_limit_cents` padrão. É o número que a tela mostra e que `lib/ai/budget/check.ts` lê para decidir throttle — a IA do clone podia parar sem nenhuma chamada nova. O conserto **não é `disable trigger`**: isso deixaria a peça do recomputo de pé e, pior, um `update.sh` que morresse entre o disable e o enable deixaria a instalação sem contador nenhum, em silêncio. Aqui a última palavra é um recomputo que roda DEPOIS do backfill e **atribui** (não incrementa) o gasto real do mês, contando cada linha uma vez (`not exists legacy_invocation_id`) — vale qualquer que tenha sido o estado deixado pelo trigger, e re-aplicar chega no mesmo número. **Muda de comportamento, declarado:** o total do mês passa a incluir o que os workers legados gastaram; esse dinheiro sempre saiu, só não era contado. `tests/invariants/orcamento-apos-backfill.test.ts` fixa a propriedade executando os DOIS blocos extraídos do baseline (não reimplementados) — sabotar o `not exists` dá 4 vermelhos, como previsto. |
| `20260808040000` | `0139_kind_check_completo` | **Forward-fix da 0129, que encolheu o vocabulário de `agent_inbox_items.kind`.** A 0129 reconstruiu `agent_inbox_items_kind_check` com os 15 valores que conhecia enquanto o vigente já tinha 18, apagando `contact_proposal_expired` (criado pela 0124, a migration IMEDIATAMENTE anterior), `promise_unfulfilled` e `other`. O dano não é cosmético e é mudo: em quem aplica migrations em ordem (`supabase db push`), os INSERTs de `lib/contacts/proposta-de-dado.ts` (dado ouvido que venceu sem alguém conferir) e de `lib/agent-engine/agent/operator-turn.ts` (promessa não cumprida) passam a violar a constraint, o `catch` fire-and-forget engole, e o operador **nunca vê o aviso** — o mesmo desfecho que a 0129 existia para evitar, por outra porta. Quem usa o kit (`install.sh`/`update.sh`) NÃO foi afetado: o kit aplica só o `baseline.sql`, cujo bloco único sempre teve os 18 — por isso esta migration **não tem apêndice novo no baseline**, e acrescentar um segundo bloco reconstruindo a mesma constraint seria o defeito da issue #159 que `baseline-constraint-reconstruida.test.ts` proíbe. A classe, e não só a instância: `tests/unit/kind-check-migration-x-baseline.test.ts` passa a exigir que a ÚLTIMA migration que reconstrói a constraint bata valor a valor com o baseline — a comparação que nunca existiu, porque `pnpm test:db` aplica só o baseline, que estava certo. |
| `20260807060000` | `0122_telefone_do_lid` | **O telefone do contato @lid, que sempre chegou e nunca foi lido.** Medido em `webhook_events_log` da produção: 76 de 76 payloads `@lid` trazem o número em `_data.key.remoteJidAlt` — a leitura de que "@lid é opaco" valia para o `from`, não para o payload. Gravar esse telefone, porém, mudava a `wa_identity` GERADA de `lid:` para `phone:`, quebrava o reencontro pelo `on conflict` e **duplicava o contato** (o defeito que a 0027 matou). Por isso entra `contacts.wa_lid`: correlação do WhatsApp que não depende do telefone, com índice único e dedup auto-curativa ANTES da constraint. `fn_upsert_wa_contact` ganha o 7º parâmetro e passa a COMPLETAR o que falta em vez de só congelar o nome (a versão de 6 params é dropada). Inclui o backfill dos `display_name` técnicos legados (`Contato 543134@lid`), com `is_anonymized = false` para não reverter anonimização. |
| `20260807070000` | `0123_fila_de_confirmacao_de_dado` | **O dado que o cliente diz na conversa espera confirmação humana** (spec 17 §4b). A IA propõe; uma pessoa confirma. Sem isto, um e-mail dito de brincadeira substituiria o correto e o valor antigo não existiria em lugar nenhum. Tabela `contact_field_proposals` com a FORMA de `crm_lead_reactivations` (prazo obrigatório, decisão datada, decisor) e a chave certa — contato+campo, porque a proposta é sobre a pessoa. O índice único parcial `where status='pending'` **é** a idempotência: a décima proposta do mesmo e-mail bate 23505 no banco, e não numa checagem racy. Guarda `valor_anterior` desde o nascimento — é o `from` que a L-06 exige, disponível ANTES da decisão. LGPD por TRIGGER no estado (`is_anonymized` virou true), não por chamada dentro do cascade: há mais de um caminho que anonimiza, e pendurar no fato cobre todos, inclusive os que ainda não existem. |
| `20260807080000` | `0124_kind_proposta_vencida` | **A Central avisa quando um dado ouvido venceu sem alguém conferir.** A 0123 criou a fila com `expires_at` obrigatório, e prazo que ninguém cobre é pior que nenhum: promete um limite inexistente e a pendência vira badge permanente, que SIMULA atenção. Vencer é decisão — a do relógio —, mas decisão que só acontece no banco é invisível. Kind `contact_proposal_expired`, severidade `info` (nada quebrou; tratar como falha ensinaria a ignorar os avisos que são falha). Entra na lista EXISTENTE, no bloco único que reconstrói a constraint (#159). |
| `20260807090000` | `0125_escopo_de_funil_do_agente` | **O agente só opera nos funis marcados** (spec 17 passo 3). Medido: uma organização com 4 funis e 5 agentes de negócios diferentes (um SDR, dois suportes, uma atendente de clínica) — todos alcançando todos. `ai_agent_versions.pipeline_ids uuid[] default '{}'`: vive na VERSÃO para a permissão ser publicada junto com o resto; vazio = NENHUM funil. **Backfill derivado do histórico real** (os funis onde cada agente já registrou atividade): sem ele, no dia do deploy todo agente em produção pararia de mexer em card, de uma vez e em silêncio. Medido: só 1 de 8 agentes tem histórico. Traz o CONSERTO do `fn_ai_agent_version_content_immutable`, que parava no `followup` e ignorava as nove colunas posteriores — escopo de permissão editável em versão publicada é a ausência de escopo com aparência de controle. |
| `20260811110000` | `0150_rbac_na_config_de_ia_e_canais` | **A RLS isolava a organização, mas não aplicava o PAPEL — e o `requireRole()` das rotas não é a única porta.** Segundo achado do mesmo relatório da comunidade, e o mais consistente dele. Medido no baseline da main: das **82** policies `ALL` de `public`, **71** não citam `fn_role_at_least` — só tenancy, via `fn_user_org_ids()`, que devolve organizações e nada mais; e `authenticated` tem SELECT/INSERT/UPDATE/DELETE nessas tabelas. Isso importa porque o PostgREST é exposto ao browser **por construção** (URL + `anon key` vão no bundle): um usuário logado fala com ele direto, com o próprio JWT, e as rotas Next ficam de fora do caminho. Provado num pg17 com este baseline, membro papel **`viewer`**: reescreveu `ai_agents.system_prompt` (o texto que o bot fala com cliente real), derrubou `channel_sessions` (o canal de WhatsApp) e **DELETOU** a linha de `ai_provider_credentials` (mata a IA da org) — 4 de 4 ataques, com o controle mostrando que a tenancy vale (o viewer não alcança a organização vizinha). Depois: 0, 0, *permission denied*, *permission denied*, com o viewer **ainda lendo** (senão a tela quebra) e o admin escrevendo tudo. **Escopo deliberado:** só as tabelas de CONFIGURAÇÃO de IA e canais, onde o dano é inequívoco e onde a rota já exige `admin` (channel-sessions route.ts:61, ai/agents route.ts:67, ai/budget route.ts:46) — a policy passa a espelhar a API em vez de ficar três níveis mais frouxa. As outras ~63 ficam para depois **de propósito**: `job_queue`, `llm_calls`, `send_ledger`, `metrics` e afins são escritas pelo motor, e apertá-las no mesmo fôlego trocaria um furo de segurança por uma parada de produção; a lista está congelada numa allowlist no invariante, que nasce verde sobre a dívida atual e **vermelho na próxima tabela nova** — com um controle que reprova se a allowlist guardar tabela inexistente. **O segredo cifrado sai por COLUNA, não pela tabela:** `ai_provider_credentials_safe` é `security_invoker=true` de propósito (para a RLS da base valer para quem consulta), então revogar o SELECT inteiro quebraria a view e a tela — foi o controle positivo do invariante que reprovou a primeira versão desta migration. Com grant por coluna, `api_key_encrypted`/`iv`/`tag` ficam inalcançáveis pelo PostgREST e as doze colunas da view seguem legíveis. O par SELECT+escrita preserva `or fn_is_platform_admin()` onde já existia, senão o super-admin perde acesso e o suporte cega. Worker não entra na conta: usa `service_role`, que é `bypassrls`. |
| `20260811100000` | `0149_definer_valida_membership` | **Duas `SECURITY DEFINER` confiavam no `organization_id` do ARGUMENTO, e isso é vazamento entre organizações.** Veio de um relatório de segurança de um usuário, auditando a tag **v1.0.0**. A metade dele sobre ACL ("definer executáveis por `anon`") **já estava fechada** pela 0108 e pela 0116 — medido, 0 de 31 hoje contra 14 de 26 na v1.0.0, mesmo instrumento nos dois lados. Mas **ACL e MEMBERSHIP são defeitos independentes**, e é exatamente por isso que este sobreviveu com o gate verde: a varredura da 0116 (`hardening-definer-varredura.test.ts`) pergunta *quem pode executar*, nunca *a função confere de qual organização é quem executou*. `emit_event` e `retrieve_top_k_chunks` são — corretamente — executáveis por `authenticated`, têm call site com sessão de usuário, e usavam o `p_organization_id` recebido como único filtro de tenant; rodando como `postgres`, contornam a RLS. Medido num pg17 com este baseline, usuário papel **`viewer`** membro só da org A, como role `authenticated` com o `sub` dele: `INSERT` direto em `event_log` da org B → *permission denied*; `SELECT` direto em `ai_chunks` da org B → *0 linhas* (**a RLS está de pé**, o furo é da função); `emit_event(..., org => B)` → **gravou na org B**, que é a fila que workers e automações consomem; `retrieve_top_k_chunks(B, kbv)` → **devolveu o conteúdo da base de conhecimento da org B**. O remédio é o guard que este schema **já** usa em `fn_conversation_assign`: `if auth.uid() is not null and not fn_role_at_least(org, 'viewer')`. O `auth.uid() is not null` é a parte que torna isto seguro sem quebrar o produto — worker, cron e trigger chamam com `service_role` (sem JWT, `auth.uid()` nulo) e passam direto, como sempre passaram; quem ganha a checagem é só a sessão de usuário. Papel mínimo `viewer` porque o que se barra aqui não é o papel fraco, é a **organização alheia**. `fn_log_event` delega a `emit_event` e herda o guard — não ganha cópia da regra. Provado nas duas direções (ataque falha **e** uso legítimo continua): `tests/invariants/definer-valida-membership.test.ts`, com controle positivo de leitura na própria org e do worker `service_role` — sem eles, uma função que lançasse exceção sempre deixaria a suíte verde pelo motivo errado. **Cuidado ao reescrever:** `create or replace` recusa renomear parâmetro e coluna de retorno, e os nomes reais são `p_embedding`/`p_threshold` (default 0.40) e `knowledge_source_id` — nomes divergentes criam **sobrecarga nova** e deixam a versão sem guard viva; um `do $$` no bloco avisa se aparecer. |
| `20260811090000` | `0148_caso_anuncia_no_barramento` | **O caso de escalação passa a anunciar abertura e fechamento no `event_log`.** `agent_cases` é o instante em que o sistema declara "isto precisa de gente" e não emitia NADA no barramento — grep por `emit_event` em `lib/agent-engine/`, `lib/escalacao/` e `app/api/v1/ai/cases/` devolvia zero (controle positivo: a mesma sonda acha `lib/leads/agent-stage-sync.ts:308`). Nenhum consumidor podia reagir a um caso, e é isso que destrava o gatilho de follow-up por CASO ABERTO. Por TRIGGER e não por emissor em código, mesma doutrina da 0138: a abertura tem um escritor (`openCase`) mas o FECHAMENTO tem CINCO (`provideCaseUpdate`/`resolveCaseFromHuman`/`markAwaitingLead`/`escalateCase`/`encerrarChamadoPeloAgente`) — caçar emissor deixa a garantia dependendo de alguém lembrar e o próximo caminho nasce mudo; com trigger a garantia é da TABELA e vale para motor, seed, script e rota futura. Não viola o anti-pattern nº 9: a proibição é *trigger fazendo HTTP*, e aqui é SQL puro sem I/O, como `fn_emit_conversation_routing` (0040) já faz. **O fechamento entra JUNTO com a abertura, e isso é desenho, não escopo extra:** medido, o caso de referência fechou em **6 segundos** — follow-up nascido de caso e nunca cancelado só morre por esgotamento ou resposta, e o cliente seria cobrado sobre um problema já resolvido (invariante 7 do Sistema Vivo, o laço tem de fechar). Anti-eco: o consumidor escreve em `followup_enrollments` e NUNCA em `agent_cases`, e o trigger de UPDATE é `of status` + `old.status is distinct from new.status`, então toque em `updated_at`/`summary`/`followup_attempts` não emite. `security definer owner postgres` com as DUAS origens de EXECUTE revogadas (item 9 da doutrina). Idempotente: `create or replace` + `drop trigger if exists`. |
| `20260810160000` | `0147_relogio_do_banco` | **Quem agenda usava o relógio do processo; quem reclama usa o do banco.** `next_eval_at` era gravado com `new Date()` do Node e `fn_claim_due_followup_enrollments` compara com `now()` do Postgres — medido nesta máquina, 12 amostras, o banco fica **17 a 34 ms atrás**. O instante que o processo chama de "agora" ainda é FUTURO para o claim: o tick seguinte não reclama e o enrollment espera o tick DEPOIS, **até 60 s** com o cron de minuto em minuto. Dói mais em `reactivity.ts`, que marca "acorde agora" QUANDO O LEAD RESPONDE — um minuto de silêncio depois de a pessoa falar com a gente, num sistema cujo propósito é não deixar ninguém esperando; em `gatilho-etapa.ts` e `silence-sweep.ts` o efeito é o follow-up nascer e perder o primeiro tick, mesmo mecanismo e custo menor porque ninguém aguarda do outro lado. **Não se corrige com margem:** subtrair milissegundos do relógio do processo é número mágico que depende de máquina, carga e distância até o banco — calibrado num ambiente, defeito adormecido em outro; e afrouxar o claim para tolerar futuro próximo relaxaria a condição para todo agendamento, inclusive os legítimos no futuro. A correção é os dois lados usarem o MESMO relógio, e são duas ferramentas porque são dois casos. **(1) Nascimento (INSERT):** `next_eval_at` ganha `default now()` — quem agenda "para agora" OMITE a coluna e o instante nasce do banco, com zero round-trip e nada para chamar errado. Aditivo e retrocompatível: `default` só age na AUSÊNCIA da coluna, então todo insert que hoje passa valor explícito continua idêntico. **(2) Retomada (UPDATE):** `default` não alcança UPDATE e o `supabase-js` grava VALOR, nunca EXPRESSÃO (`set next_eval_at = now()` não é exprimível pelo client), daí `fn_agora()` — um round-trip, e só nos eventos que de fato acordam alguém. A função nasce com os grants revogados das DUAS origens (o `alter default privileges … to anon` do baseline, que `revoke from public` não remove, e o grant a PUBLIC que o Postgres dá na criação, que `revoke from anon` não remove). |
| `20260810122000` | `0146_claim_justo_entre_organizacoes` | **O tick do follow-up servia uma organização de cada vez.** `fn_claim_due_followup_enrollments` levava os `p_limit` (20) vencidos mais antigos GLOBALMENTE, e quem acumulou fila tem por construção os `next_eval_at` mais antigos — então uma organização atrasada ocupava o lote inteiro. Medido em pg17 descartável, teto 20: com 25 vencidos na grande e 1 na pequena, o tick 1 leva 20 da grande e **zero** da pequena; com 300 na grande, a pequena só é atendida no **tick 16** (≈16 min, com o cron de minuto em minuto do `docker-compose.prod.yml`). **Correção do diagnóstico que circulava:** não é inanição permanente — o lease segura o lote reclamado, o ponteiro avança, e a organização pequena entra em `teto(K/20)` ticks, onde K são os vencidos mais antigos que ela. É atraso proporcional à fila do vizinho; continua sendo defeito porque o atraso não tem limite superior e quem o paga não tem como saber por quê. Passa a ser **rodízio**: o mais antigo de CADA organização, depois o segundo de cada, até fechar o teto. Com UMA organização o resultado é idêntico ao de antes (os 20 mais antigos), então a instalação de operador único — a de hoje — não muda em nada; medido: 20 reclamados, e os 5 mais novos ficam de fora. O `limit p_limit` DENTRO do lateral não é cosmético: sem ele o sort final ordenaria todos os vencidos do banco, e com ele o custo passa a depender do número de organizações com fila, não do tamanho dela. `for update skip locked` sai da subquery e vira CTE porque o Postgres não o aceita junto de window function — a propriedade (dois workers nunca pegam a mesma linha, nenhum espera pelo outro) é a mesma, aplicada ao conjunto já escolhido. Índice parcial `(organization_id, next_eval_at)` novo para o lateral. `tests/invariants/followup-claim-justo.test.ts` chama o adapter de produção (`createPgAdminClient`); sabotado de volta para a versão global, 2 dos 4 casos reprovam — exatamente os dois que envolvem mais de uma organização, como previsto. |
| `20260810121000` | `0144_followup_timing_plan` | **O modo adaptativo do nó de espera existia na tela e não existia no motor.** O construtor de fluxos oferece "Adaptativo (min–max)" com mínimo, máximo e um campo de orientação; `node-handlers.ts` fazia `mode === 'fixed' ? duration_ms : max_ms` — o fluxo esperava SEMPRE o máximo, e a orientação escrita pelo operador não era lida por ninguém. O decisor por LLM estava escrito (`decideFollowupTiming`) e sem produtor: `processNode` nunca devolvia `enqueue_turn` para um nó `wait`, então o ramo que carregava o `guidance` era código morto — o comentário no próprio arquivo admitia "SEM PRODUTOR VIVO hoje". Controle decorativo é pior que controle ausente: ele mente e some sem deixar rastro. A coluna guarda o plano decidido **uma vez, no acionamento** (nó `trigger`), para TODAS as esperas adaptativas de uma vez — estratégia sobre a sequência, não reflexo por nó. Isso também dissolve a race que travou a feature: um turno de decisão POR nó criaria um terceiro estado indistinguível dos dois que `resolveWaitPhase` já desempata; sem turno por nó, não há terceiro estado. E custa uma chamada de modelo por follow-up em vez de N. O modelo **propõe**, o nó **decide**: cada proposta é grampeada em `[min_ms, max_ms]` contra o grafo pinado, com `clampado` e o `proposto_ms` original guardados para o dossiê poder dizer "a IA pediu 3 dias, o seu limite era 12h", mais um `motivo` em pt-br que é o que a tela mostra ao operador. **Sem CHECK e sem NOT NULL, declarado:** `null` significa "ainda não planejado" e é o estado de todo enrollment anterior — os dois caem no comportamento antigo (o máximo), então não há dado a corrigir antes; um CHECK de shape sobre `jsonb` quebraria o `update.sh` de um clone que já tivesse gravado qualquer coisa ali, e quem valida é `lib/followup/timing-plan.ts`, que degrada para o máximo diante de plano ilegível em vez de derrubar o tick de todo mundo. Turno de planejamento que nunca volta não mata o enrollment: após `MAX_PLAN_RECHECKS` o trigger segue **sem** plano e grava o evento `timing_plan_desistido` — degradar é aceitável, perder o follow-up não. |
| `20260807160000` | `0116_definer_nova_nasce_exposta` | O buraco que a 0108 (issue #128) não fecha: ela revogou `anon` numa **lista** de 8 funções, medida num banco instalado do ZERO — e quem ATUALIZA tem outro estado. O `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` que o baseline carrega por paridade com o Supabase não é uma linha cujo efeito acaba quando ela passa: grava uma entrada em `pg_default_acl`, que fica no catálogo do banco, e a partir dali **toda função criada em `public` nasce com `EXECUTE` para `anon`** — inclusive as de toda migration futura. Numa instalação nova não incomoda (o dump cria as funções antes da linha). Medido numa VPS real em 2026-08-07, comparando com o que um `install` fresco produz: 7 divergências, todas permissivas — 6 definer expostas a `anon` (entre elas `fn_decrypt_oauth`, alcançável pela **anon key, que vai para o browser** — o mesmo desenho que a 0108 chamou de letal, reaberto pelo caminho de update) e 5 a `authenticated`. A cura é **varredura, não segunda lista**: lista conserta o estoque de hoje e reabre no próximo `create function`. O bloco percorre todas as `security definer` de `public`, revoga das DUAS origens (`public` e `anon` — cada revoke sozinho deixa a outra em pé, a lição da 0108) e **devolve o privilégio efetivo de `authenticated`/`service_role` medido ANTES**, para tirar `anon` sem tirar leitura de ninguém. Desfazer o `ALTER DEFAULT PRIVILEGES` seria tratar a origem e foi tentado primeiro — não serve: ele vem do `pg_dump` no CORPO do baseline e é reescrito a cada re-aplicação, então o revoke duraria até o próximo `update.sh`. A varredura roda no FIM do apêndice, depois de tudo que cria função, e por isso cura no mesmo run em que o defeito nasceria — posição que `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts` vigia. Para `authenticated` a regra é nominal (5 revokes) e não varredura: ele PRECISA de `EXECUTE` nos helpers de RLS (a policy é avaliada com o papel de quem consulta) e em `retrieve_top_k_chunks`. Idempotente (revoke de privilégio ausente é no-op). |
| `20260810120000` | `0145_dossie_do_followup` | **O follow-up deixa de ser caixa-preta, e pausar deixa de ser matar.** `followup_enrollment_events` grava cada passo do motor desde a 0054 e NENHUMA tela lia a tabela: a fila dizia que existe um follow-up e não dizia nada do que já aconteceu nele — o dado existia e era invisível, que é a forma de falha que o invariante de log visível existe para proibir. E a única intervenção disponível era cancelar, porta de saída e não intervenção: segurar o fluxo por dois dias custava matá-lo e perder a caminhada. Três peças. **(1)** `timing_plan jsonb` — o plano de atrasos que o agente decide UMA VEZ ao acionar o follow-up (contrato fechado com a 0144, que o produz). Criado aqui com `if not exists` de propósito: o consumidor é o dossiê, e ele não pode depender da ORDEM em que as duas migrations chegam ao banco de um clone — coluna que falta vira `42703` e derruba a rota inteira, não o bloco que a usa. **(2)** o status `paused_manual`. `paused_handoff` JÁ existe e significa outra coisa ("um humano assumiu a conversa"), e quem o retoma é `reactToHandoffClose` quando o handoff fecha — usar aquele estado para a pausa manual faria o sistema retomar sozinho, em silêncio, um follow-up que uma pessoa mandou parar, na primeira vez que qualquer handoff daquele contato fechasse. Os dois CHECKs são dropados pelo CATÁLOGO (definição que fala de `paused_handoff` e ainda não conhece `paused_manual`), nunca por nome fixo: num clone que passou por dump/restore o nome gerado pode ser outro, o drop não acharia nada, o `add` tropeçaria no duplicado e o `exception when duplicate_object` engoliria — `update.sh` verde e o banco recusando o INSERT que a aplicação considera válido. **(3)** o índice de unicidade do vivo passa a contar a pausa: sem isso o enrollment pausado libera a vaga do par (pointer_id, contact_id) e um segundo nasce ao lado, dois follow-ups do mesmo fluxo para a mesma pessoa no instante da retomada. Nada a deduplicar antes: o CHECK só ACRESCENTA valor e o predicado novo do índice cobre exatamente as linhas do antigo (nenhum banco tem `paused_manual` antes desta migration). Mais um índice `(enrollment_id, created_at)` para a leitura do dossiê — o único que existia é PARCIAL, serve ao dedup do motor. **Correção pega na integração:** a primeira versão recriou o índice do vivo copiando as colunas da DDL ORIGINAL (`pointer_id, contact_id`) em vez das que estavam EM VIGOR (`organization_id, contact_id`, postas por uma migration anterior com backfill de exclusividade). Mexer só no predicado a partir da linha errada reverte a garantia — o mesmo contato voltaria a poder ter um follow-up vivo em CADA fluxo — sem conflito de merge, sem erro de aplicação e sem sintoma imediato. O invariante que devia pegar isso passava por sorte: ele criava o segundo enrollment no MESMO fluxo, e assim ficava verde com qualquer um dos dois predicados. Agora ele nasce em outro fluxo, e sabotar o baseline de volta o deixa vermelho (medido: 1 vermelho, o previsto). |

| `20260809100000` | `0142_camadas_de_seguranca_por_org` | **As duas verificações que custam dinheiro viram escolha da organização.** A cadeia antes do envio tem dez verificações; oito são determinísticas, custam zero e não são escolha de ninguém (desligar o que respeita quem pediu para parar, ou o que impede o número do cliente de ser bloqueado, é sabotagem do negócio dele). Duas consultam um modelo e custam por mensagem — a segunda leitura de promessa (+1 por mensagem enviada) e o detector de manipulação no inbound (+1 por mensagem recebida) — e eram decididas por variável de ambiente do WORKER: por processo, igual para todas as organizações, alcançável só por quem edita o `.env` da VPS e reinicia o contêiner. Num produto que a pessoa instala sozinha, isso é o mesmo que não existir. Tabela `org_guardrail_layers` com PK (organization_id, layer) e RLS por `fn_user_org_ids()`. **`layer` SEM CHECK**, na exceção de vocabulário ABERTO do CLAUDE.md: clone com valor desconhecido quebraria o `update.sh`, então o vocabulário vive no TypeScript e a coluna fica fora do invariante de vocabulário. **Ausência de linha NÃO é "desligado"** — sem linha vale o ambiente, e é isso que faz aplicar a migration não mudar o comportamento de quem não pediu nada; por isso a leitura devolve três estados (ligado, desligado, não escolhido) em vez de um booleano. Não reusa `ai_purpose_bindings.is_enabled`: lá `false` significa "ignore esta escolha de modelo", e mudar o significado quebraria 23 pontos para servir 2. |
| `20260810150000` | `0143_guardrail_layers_escrita_de_admin` | **Forward-fix da 0142: desligar uma camada de segurança era escrita de QUALQUER membro.** A 0142 criou `org_guardrail_layers` com uma policy `for all` org-flat e deixou o gate de papel só na rota (`admin` no PUT). Rota não é fronteira: o `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon, authenticated` deste baseline vale para toda tabela criada depois dele, então com a anon key (que vai ao browser) e o próprio JWT um `viewer` desarmava a defesa anti-jailbreak da organização pelo PostgREST, sem auditoria — medido num pg17 do zero: UPDATE 1 + INSERT 1. Passa à forma canônica do repo: `org_guardrail_layers_select` (leitura org-flat) + `org_guardrail_layers_admin_write` (`fn_role_at_least(org,'admin')`, cobrindo UPDATE e INSERT porque o PUT é upsert), mais `revoke all ... from anon` (precedente 0123). O raciocínio que produziu o furo estava escrito na 0142 — "nenhuma função nova, então não há grant a revogar" —, leitura errada de uma doutrina que fala de FUNÇÃO: tabela nova também nasce concedida. Guardado por `tests/invariants/camadas-de-seguranca-rbac.test.ts` (papel + privilégio de `anon`, com controle positivo em `authenticated`) e pela entrada de `org_guardrail_layers` em `rls-isolation.test.ts` — o teste de schema conecta como `postgres` (`rolbypassrls`), e por isso passava verde com a policy sabotada. |
| `20260810180000` | `0153_mensagem_editada_e_apagada` | **A mensagem que o cliente editou ou apagou no aplicativo.** O evento chegava (quando assinado), o parser não o reconhecia, a rota respondia 200 e a linha ficava como estava — o atendente lia a versão velha, ou lia uma mensagem que do lado do cliente não existe mais. Sem erro em lugar nenhum, que é o que torna caro: combinar preço, prazo ou endereço a partir de um texto já corrigido gera divergência que ninguém rastreia depois. **Duas colunas, não um estado:** editada continua valendo (o texto novo é o que conta) e apagada deixou de valer (o texto não pode mais aparecer) — um `estado text` colapsaria as duas e obrigaria a tela a perguntar qual é antes de renderizar. **Timestamp e não booleano** porque a pergunta seguinte é "quando?", e "editada agora" e "editada ontem" pedem leituras diferentes numa negociação. O corpo é SOBRESCRITO e o original não é guardado: o CRM tem que mostrar o que o cliente vê agora, e histórico de edição é outra feature, com tela e retenção próprias — fazê-la pela metade acumularia dado pessoal num campo que a anonimização LGPD não conhece. A linha apagada não é removida: sumir com ela levaria o contexto das vizinhas e o histórico de quem atendeu. Aditiva e idempotente. |
| `20260810210000` | `0154_template_sabe_de_qual_conexao` | **O espelho de definições passa a saber de QUAL conexão cada uma é.** `meta_templates` nasceu para um canal só, e a única marca de origem é `waba_id` — o id da conta na plataforma da Meta. Um segundo canal, com conta de outro formato, não tem onde entrar sem mentir sobre o que aquele campo significa. Medido: o endpoint resolve a sessão por `metaSessionForOrg`, então numa instalação com o canal intermediado e SEM o oficial ele devolve lista VAZIA — e o operador conclui que não tem nenhuma definição aprovada quando tem várias. **A conexão, e não um `provider text`:** a pergunta da tela é "quais posso usar NESTA conexão?", e dois números do mesmo provider têm definições diferentes; um campo de tipo responderia as duas com a mesma lista. É `R` da DIRC — a conexão já existe, aqui basta o ponteiro. **NULL é estado legítimo:** as linhas anteriores vieram do canal oficial e ninguém sabe de qual sessão são; inventar uma seria pior que admitir. **`on delete set null`, não `cascade`:** apagar uma conexão não pode levar junto o registro do que a plataforma aprovou — a definição continua existindo lá, e quem reconectar depois quer encontrá-la. Aditiva e idempotente. |
| `20260811200000` | `0151_arquivo_do_webhook_por_canal` | **O arquivo do corpo cru do webhook passa a aceitar os canais que entraram depois.** `webhook_events_log` é o ÚNICO lugar onde o payload cru do provedor fica guardado, e a rota do canal por QR grava lá desde sempre. A rota genérica de canal — por onde entram os canais oficiais — nunca gravou nada: barrido feito antes desta migration, zero escritores naquele caminho. Não é lacuna abstrata: o comentário de `lib/waha/ingest.ts` mostra que a decisão sobre identidades opacas (`@lid`) só foi possível porque esse arquivo existia em produção — "76 de 76", contados no banco. O instrumento que respondeu aquela pergunta faltava justamente no canal NOVO, que é onde ainda há pergunta aberta (se mensagem de grupo chega; de qual host vem o anexo). **Por que uma migration e não gravar `'generic'`:** a coluna diz QUAL provedor mandou, e escrever "genérico" para um canal que se sabe qual é é a string que mente — o anti-pattern nº 1 da doutrina, no exato lugar que existe para investigar. **Por que é seguro:** é ALARGAMENTO — um CHECK que aceita mais valores não pode ser violado por linha que já passava pelo antigo, então não precisa de backfill nem deduplicação antes. O apêndice do baseline tem o BLOCO ÚNICO desta constraint (regra da issue #159): canal novo edita aquela lista em vez de acrescentar um segundo bloco. Idempotente. |
| `20260811210000` | `0152_o_gatilho_manda_o_texto` | **O emissor único passa a carregar o payload completo.** A 0145 acompanhou a remoção da SEGUNDA emissão de `message.received` que o ingest por QR fazia (o gatilho já emite para todo canal, e os quatro consumidores rodavam duas vezes por mensagem — sentimento cobrado em dobro). Só que os dois payloads NÃO eram iguais: o do app tinha `body_preview` e `channel_session_id`, o do gatilho não. `body_preview` é lido pela condição **"Texto da mensagem"** das automações (`event.body_preview`, a ÚNICA condição de texto oferecida para o gatilho `message.received`) e interpolado em `{{event.body_preview}}` nas ações; sem ele, uma regra que o usuário configurou para reagir ao CONTEÚDO da mensagem deixa de casar **em silêncio** — não dispara e nada acusa por quê. **Por que acrescentar ao gatilho e não devolver a emissão dupla:** o certo é o emissor único carregar o payload completo, e assim o canal oficial — que nunca teve `body_preview` — passa a ter também: a condição de texto funciona nos dois canais pela primeira vez. **Por que 280:** é o mesmo corte da emissão removida; mudar o tamanho mudaria em silêncio o resultado de uma regra "contém" já configurada. **`left()` e não `substring()`:** aceita NULL e devolve NULL, que é o certo para mensagem sem corpo (mídia sem legenda) — a condição não casa, em vez de casar com string vazia. `create or replace`, idempotente. |
| `20260813120000` | `0156_quadro_do_onboarding` | **O quadro de clientes montado no onboarding, numa transação só.** `trg_seed_default_pipeline_for_org` semeia o MESMO funil de e-commerce em toda organização criada — "Carrinho abandonado", "Em separação", "Enviado" — e num produto multi-nicho a clínica abre o quadro dela e lê isso. O gatilho fica: uma organização precisa nascer com ALGUM funil, senão o primeiro lead não tem onde entrar; o que faltava era o passo do wizard que troca esse quadro por um do ramo. E tem a metade invisível, medida neste banco em 2026-08-13: **312 etapas em 43 funis, 4 com `agent_stage_hint`** — e as 4 são de organizações de teste. Toda instalação real nasce com `coberturaDoFunil()` devolvendo `mudo: true`: o assistente tem o funil no escopo e não sabe o que significa nenhuma coluna. Por isso a gravação insere o DESTINO junto com o nome. **Por que uma função e não quatro chamadas do cliente:** a troca é DELETE + INSERT, e os três índices únicos (`_won`, `_hint`, `_slug`) são imediatos, então as duas gerações não coexistem; como o cliente JS não tem transação, um DELETE que passa com um INSERT que falha deixaria o funil SEM COLUNA NENHUMA — pior que o quadro errado, e num passo onde ninguém sabe o que aconteceu. **Duas recusas, e a segunda é a silenciosa:** funil com negócio (`crm_leads_stage_id_fkey` é RESTRICT, o erro cru chegaria à tela) e etapa que é destino de fonte de webhook — `webhook_sources.default_stage_id` é ON DELETE **CASCADE**, então o DELETE não falharia, apagaria a fonte inteira em silêncio e as integrações parariam sem nada ficar vermelho. Devolve o motivo em jsonb, não `raise`: as duas são estados normais que a tela explica em português. **EXECUTE só para `service_role`** — medido: com `revoke from public, anon` apenas, `authenticated` continuava podendo, e aqui isso é o furo da 0149 de volta (a organização vem por argumento, a função roda como `postgres`). Idempotente. |
| `20260808180000` | `0119_contato_lookup_telefone` | Carimbo da última TENTATIVA de descobrir o telefone por trás da identidade opaca. O WhatsApp passou a identificar quem escreve por um id opaco em vez do número, e uma instalação real acumula contatos sem telefone (medido: **52 de 54**). O canal sabe traduzir, mas a tabela de tradução é povoada por ATIVIDADE — hoje não tem a resposta, semana que vem talvez sim. Uma varredura que escolhesse "sem telefone" sem carimbar reprocessaria SEMPRE os mesmos primeiros N e os do fim da fila nunca seriam perguntados; com o carimbo a ordem é "quem foi perguntado há mais tempo primeiro" e a fila gira. **NULLABLE de propósito:** NULL = nunca perguntado; com valor e `phone_number` ainda nulo = o canal não sabia na ocasião. Um `not null default now()` colapsaria os dois estados e faria contato novo nascer como "já tentado", que é justamente a distinção que a varredura precisa. Índice PARCIAL (`where phone_number is null`) porque só a minoria sem número interessa, e ela encolhe com o tempo. Aditiva e idempotente. |
| `20260808200000` | `0120_avisos_de_canal` | Dois kinds novos na Central: `channel_template_review` e `channel_number_alert`. A plataforma revisa os modelos aprovados e mexe no número por conta própria — reprova um template, suspende a linha por falta de pagamento, exige KYC — e nada disso chegava ao operador: a descoberta acontecia no disparo que não saiu, com a campanha montada e o cliente esperando. **Dois kinds, e não um por evento:** suspenso, recusado, KYC pendente e liberado são desfechos diferentes da MESMA pergunta ("dá para enviar por este número?"), e um kind por evento encheria a Central de categorias que ninguém filtra separado; o evento exato vai no corpo do aviso. A constraint é RECRIADA no bloco único onde já vive (regra de `baseline-constraint-reconstruida`, #159): dois blocos fariam o `update.sh` de um clone com dados falhar no primeiro e deixar a tabela sem constraint entre o drop e o add. A lista foi copiada INTEIRA do baseline — a primeira versão omitia `promise_unfulfilled` e `other`, e teria apagado dois kinds em uso. Aditiva e idempotente: a lista só cresce, nenhuma linha existente passa a violar. |
| `20260813090000` | `0155_marca_da_instalacao_no_banco` | **A marca da instalação sai do `.env` e vai para o banco.** Nome, logo e cor viviam só em `APP_NAME`/`APP_LOGO_URL`/`APP_ACCENT_HEX`: trocar qualquer um exigia SSH na VPS, editar o `.env` e reiniciar a stack — para quem compra hospedagem e instala sozinho, isso é o mesmo que não ser configurável. Tabela **singleton** `platform_branding` (`id smallint primary key default 1` + CHECK `id = 1`), semeada do `.env` na PRIMEIRA leitura com `seeded_from_env = true`. **O `.env` continua sendo escrito, e isso não é redundância:** o `agent.sh` do kit, em falha de update, reverte só a IMAGEM (`APP_IMAGE`) — não o schema, não o `git checkout` — e o `update.sh` aplica o baseline ANTES de puxar a imagem. O rollback põe código antigo sobre banco novo **por construção**, e código antigo não conhece esta tabela: se a marca só existisse aqui, ela sumiria no meio de um rollback. Com o `.env` intacto ela degrada para o valor da instalação. **`seeded_from_env` não é enfeite de proveniência:** a escrita humana o zera, e a semeadura só corre sobre linha ausente ou linha `seeded_from_env = true` e vazia — é o que impede o `.env` de desfazer no render seguinte a escolha de quem apagou os campos de propósito. **`fallback_at`/`fallback_reason` são ESTADO, não configuração** (invariante 6 do Sistema Vivo): quando a serialização recusa a cor, o produto degrada para a dele, e sem essas colunas o degrade é indistinguível de "a feature nunca foi instalada" — o operador veria a cor do produto e concluiria que o campo não funciona. Gravadas e **limpas** por `lib/branding/instalacao.ts` (alarme que não apaga sozinho ensina a ignorar alarme), sempre em FORMA e nunca em IDENTIDADE: nenhum hex de marca entra no diagnóstico. **RLS ligada com ZERO policies + `revoke all ... from anon, authenticated`.** As duas coisas, e nenhuma substitui a outra: RLS sem policy é a forma explícita de dizer que o PostgREST não serve a tabela; o revoke é **o análogo, para TABELA, da regra de `security definer` do item 9 do CLAUDE.md** — o `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon/authenticated` do baseline vale para toda tabela criada depois dele, então **tabela nova nasce concedida**. Foi assim que nasceu a vulnerabilidade que a 0143 consertou (`org_guardrail_layers`: um `viewer` desligava a camada anti-jailbreak pelo PostgREST). Guardado por `tests/invariants/marca-da-instalacao.test.ts`, que mede PRIVILÉGIO **e** comportamento (`set role anon` → 42501), com controle positivo em `service_role`. **Sem `event_log`:** nenhum dos 12 handlers de `lib/event-log/register-handlers.ts` cobriria um tipo `platform_branding.*`, e o drain deixa evento sem handler intocado — a linha nasceria `pending` para sempre em todo clone (anti-pattern nº 3). O registro é `audit()` com a ação nova `platform_branding.updated`. **`accent_hex` tem CHECK de REGEX (`^#[0-9a-f]{6}$`), não de conjunto**, e por isso NÃO entra na lista `PARES` de `tests/invariants/vocabulario-banco-x-typescript.test.ts`, cujo extrator só reconhece `= ANY (ARRAY[...])`. Aditiva e idempotente. |
| `20260814090000` | `0157_marca_por_organizacao` | **A marca de cada organização se grava em UMA instrução, por função.** O revendedor hospeda várias empresas e cada uma quer o próprio nome e a própria cor dentro do produto; o lugar do dado é `organizations.settings.branding`. O que essa migration resolve não é onde guardar, é COMO escrever: `organizations.settings` já tinha **três donos com gates diferentes** — a aba Organização (admin, `updateTenant.ts`), o PATCH de atendimento (manager) e a régua de atrito (manager) — e os três leem o jsonb INTEIRO, espalham em memória e regravam o objeto inteiro em round-trips separados, sem transação, sem lock e sem versão. **A perda foi medida, não deduzida:** o manager restringe `visibility_mode` de `all` para `own`, o admin salva a aba de identidade com o snapshot velho, e o valor volta para `all` — sem erro em lugar nenhum. E `visibility_mode` é lido DIRETO pela RLS, dentro de `fn_can_view_conversation` e `fn_can_view_lead`: um write de COR reverteria, em silêncio, uma decisão de exposição de dado de cliente. Um quarto escritor com o mesmo padrão era inaceitável, então a marca por organização passa por `fn_definir_marca_da_organizacao(p_org, p_actor, p_marca)`, que faz `jsonb_set` numa instrução só — **o objeto nunca sai do banco**, e as chaves irmãs (`llm`, `routing`, `visibility_mode`, `atrito`, `lost_reasons_extra`, `plan`) não têm como se perder. **Devolve `integer`, não `void`:** a única policy de escrita de `organizations` é `orgs_write_platform_admin` (FOR ALL, `USING fn_is_platform_admin()`), então pelo client de sessão o UPDATE de um admin de TENANT casa 0 linhas e o PostgREST responde 204 — `error` chega null no supabase-js, a tela diz "salvo" e nada foi gravado (issue #144). O `row_count` é o que permite ao chamador distinguir "gravou" de "não gravou", e a action reprova `data !== 1`. **A autorização é REPETIDA dentro da função** (admin da própria org, ou platform admin, ambos com `revoked_at is null`): o gate da Server Action usa o snapshot de membership de `loadAuthUser()`, não o banco, e Server Action não passa por `requireRole` — repetir a checagem aqui é o que faz a regra valer para qualquer chamador futuro e o que impede escalação caso o EXECUTE escape um dia. Papel insuficiente levanta **42501**; forma inválida levanta **22023**; falhar alto em vez de devolver 0 existe porque 0 já significa "a organização não existe", e colapsar os dois deixaria o chamador sem saber se o problema é papel ou id. **`accent_hex` valida com a MESMA regex do CHECK `platform_branding_accent_hex`** (`^#[0-9a-f]{6}$`, a forma que `normalizarHex` emite): dentro de jsonb não cabe CHECK de coluna, então a regra é da função — e por não ser CHECK de CONJUNTO ela **não** entra na lista `PARES` de `tests/invariants/vocabulario-banco-x-typescript.test.ts`. **Os dois revokes do item 9 do CLAUDE.md** (`from public, anon, authenticated`) + `grant to service_role`: são origens distintas de EXECUTE e tratar só uma deixaria a função exposta com o gate verde — e como esta é VOLÁTIL, `authenticated` sairia com escrita cross-tenant na mão. **Sem `event_log`:** nenhum dos 12 handlers de `register-handlers.ts` cobre `org.*`, e o drain deixa evento sem handler intocado — a linha nasceria `pending` para sempre em todo clone (anti-pattern nº 3). O registro é `audit()` com a ação nova `org.branding_updated`, carregando FORMA e nunca o hex. Guardada por `tests/invariants/marca-da-organizacao.test.ts` (7 casos, incluindo a catraca do 0-linhas com controle positivo e a sonda de privilégio nos três papéis). `create or replace`, sem constraint nova sobre dado existente: idempotente e sem backfill. |
| `20260814140000` | `0158_logo_no_storage` | **O logo sai da caixa de texto e vira ARQUIVO.** Até aqui "logo" era `platform_branding.logo_url` — uma URL que alguém colava; quem instala o sistema para clientes não tem onde hospedar um PNG, então o campo ficava vazio e a marca do revendedor não aparecia. Traz o bucket **`brand-logos`**, `platform_branding.logo_path`, a função `fn_definir_logo_da_organizacao` e um **forward-fix da 0157**. **O bucket é PÚBLICO e é o primeiro do repositório** (os quatro anteriores nascem `public=false`) — razão medida: o logo é renderizado num `<img>` da tela de LOGIN, servida a quem não tem sessão, e URL assinada VENCE (a marca sumiria da fachada sozinha, e "o logo sumiu" não apontaria para a causa). A exceção é contida e o invariante `tests/invariants/marca-logo.test.ts` a mede: bucket exclusivo de logo, **ZERO policy** em `storage.objects` (público abre LEITURA pelo endpoint `/object/public/...`, não INSERT nem DELETE), caminho não-enumerável `<prefixo>/<uuid v4>.<png|jpg>`, e `allowed_mime_types` como **backstop apenas** — o Storage compara com o header que QUEM SOBE escolhe, então quem decide o tipo é o farejador de bytes de `lib/branding/logo-arquivo.ts` (SVG é BANIDO: não há CSP neste repo e SVG navegado direto executa script). **Teto de 512 KB** porque `images.unoptimized` faz o arquivo ir inteiro ao navegador em toda página e a cota do Supabase é do CLIENTE (1 GB, compartilhado com `whatsapp-media`, que não tem poda). **Grava-se o CAMINHO, nunca a URL** (DIRC-C): a URL é função determinística de caminho + host, e gravá-la amarraria a marca ao host do projeto de hoje. `fn_definir_logo_da_organizacao` **assevera o prefixo contra `p_org` DENTRO do banco** — sem isso um admin de tenant grava o `platform/...` que qualquer pessoa lê no HTML do login como logo dele e, na troca seguinte, o delete-on-replace (rodando como `service_role`) apaga o logo da instalação inteira. **O forward-fix da 0157 é a metade não-óbvia:** aquela função faz `jsonb_set(settings,'{branding}',p_marca)` e substituiria o objeto inteiro, então salvar o nome apagaria o logo — em silêncio, com a tela dizendo "salvo". Ela passa a preservar `logo_path` via `p_marca || jsonb_strip_nulls(...)`. **No `baseline.sql` o apêndice entra em DOIS blocos**, e não é estilo: `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts` proíbe `create function` depois da varredura de `anon`, mas `platform_branding` é criada num bloco que vem DEPOIS dela — bloco único quebraria o `install.sh` (`ON_ERROR_STOP=1`, tabela inexistente) ou deixaria as duas funções expostas a `anon` em quem ATUALIZA. Funções antes da varredura, bucket e coluna no fim. CHECK de `logo_path` é de REGEX, não de conjunto ⇒ **fora** de `PARES` em `tests/invariants/vocabulario-banco-x-typescript.test.ts`. Backfill ANTES da constraint (o `update.sh` roda sem `ON_ERROR_STOP` e engoliria o 23514, deixando a coluna sem validação em silêncio). Aditiva e idempotente. **Dívida declarada:** não há cron de órfãos — um logo tem ≤512 KB e muda raramente, encher 1 GB exige ~2000 uploads que falharam entre o upload e a gravação. |
| `20260814210000` | `0159_o_teto_que_vincula` | **A tela prometia "a IA pausa ao chegar no limite" e nenhuma organização estava protegida.** A tela editava `ai_budgets.monthly_limit_cents`; o enforcement lia `organizations.settings.llm.monthly_budget_cents`, um escalar num jsonb livre sem nenhum escritor de produto (o único no repositório é `scripts/smoke-llm.ts`) e lido por um Zod com `.catch()` que transforma forma errada em `null` — e `null` é ilimitado. Dois campos, duas fontes, nenhuma ligação. Traz **duas colunas em `ai_budgets`** (`enforcement_mode`, `enforcement_effective_at`), o **resgate B→A**, o saneamento de `is_throttled`, o kind `budget_warning` e **`fn_gasto_de_ia_do_mes` como definição ÚNICA de gasto**. **Três coisas que a próxima triagem vai querer sem reler o SQL:** **(a) esta migration NÃO ARMA NINGUÉM** — `enforcement_mode` nasce `'off'` pelo DEFAULT do `ALTER` (sem UPDATE, sem heurística, sem backfill) e `enforcement_effective_at` nasce `null`, e `null <= now()` nunca é verdadeiro; o único bloco que escreve `'bloquear'` é o **resgate B→A**, rotulado e delimitado, que preserva o bloqueio de quem HOJE já é bloqueado pelo jsonb — a afirmação conferível é *nenhuma organização ganha uma capacidade de bloqueio que já não tivesse*, vigiada por `tests/unit/migracao-nao-arma-ninguem.test.ts` com controle negativo. **(b) NENHUMA linha reescreve `monthly_limit_cents` fora do resgate** — é isso que torna o apêndice idempotente **sem guarda de catálogo**, e quem "simplificar" reintroduz perda da configuração a cada `update.sh`. Pela mesma razão não há `alter column monthly_limit_cents`: um desenho concorrente morreu ali (medido em pg17) pondo `set … = null` num `do $$` com o `drop not null` DEPOIS do bloco — em banco novo o UPDATE casava 0 linhas e o CI ficava VERDE, em clone instalado estourava não-nulo, o `do $$` inteiro caía, o `update.sh` (sem `ON_ERROR_STOP`) engolia, exit 0, e a coluna nova ficava INEXISTENTE, derrubando toda chamada de IA do clone com 42703 no release seguinte. **(c) o resgate tem TRÊS cortes, e cada um evita um erro medido:** `jsonb_typeof = 'number'` (o `::numeric` de uma string levantaria 22P02 no `update.sh` de um clone, e forma errada JÁ é ilimitado hoje — não resgatar é preservar); `>= 100` (o `0` significava "sem limite" na tela e "bloqueia TUDO" no enforcement, então trazê-lo desarmado é a única mudança de comportamento, e é na direção que AFROUXA); e `<= 2147483647` porque a coluna é `integer` — medido em pg17: com `1e20` no jsonb o UPDATE levanta 22003, e como ele é UM statement o resgate INTEIRO se perde em silêncio (sabotagem medida: 2 organizações resgatadas viram 0, com o `update.sh` saindo 0). **`fn_gasto_de_ia_do_mes` é `security invoker`, não `definer`:** recebe a organização por argumento e não valida membership, então definer alcançável por `authenticated` seria leitura de gasto cross-tenant. **E por ser invoker ela depende inteiramente dos próprios revokes** — o bloco `VARREDURA anon` do baseline percorre só `p.prosecdef` e NÃO cura função invoker; medido em pg17 removendo a linha, `proacl` passa a ter `anon=X` e `authenticated=X` nos dois caminhos. Dos dois CHECKs novos, só `ai_budgets_bloquear_precisa_de_teto` é **cross-coluna / de domínio** e fica fora de `PARES` em `tests/invariants/vocabulario-banco-x-typescript.test.ts` (mesma classificação dos CHECKs de regex da 0155/0157/0158). **`ai_budgets_enforcement_mode_check` NÃO é** — `check (enforcement_mode in ('off','avisar','bloquear'))` é vocabulário puro de conjunto, e o par em TypeScript existe e roda no caminho quente (`ModoDeOrcamento`, `lib/agent-engine/edge/llm/orcamento.ts`). A classificação errada atravessou desenho e implementação e mantinha a coluna fora do único gate que pega essa classe de defeito; **corrigida: o par está em `PARES`**. No `baseline.sql` o apêndice entra em **TRÊS pontos e a ordem é load-bearing**: a função ANTES do bloco da varredura de `anon` (que proíbe `create function` depois dele, e o regex não distingue definer de invoker), o kind DENTRO da lista única de `agent_inbox_items_kind_check` (regra da issue #159), e o resto no FIM. Exercitada num pg17 real nos dois caminhos: install fresco com `ON_ERROR_STOP=1` (exit 0) e clone com dados re-aplicando sem a flag (301 erros, os MESMOS 301 do controle sem esta migration, nenhum vindo destes blocos). Idempotente: a terceira aplicação deixa o estado idêntico ao da segunda. |
| `20260815120000` | `0160_ai_budgets_so_escreve_pela_rota` | **A escada, a carência e a auditoria do teto eram contornáveis pela REST do Supabase.** A 0159 pôs em `ai_budgets` os campos que decidem se (e quando) a IA para; toda a regra que os protege mora na rota `PATCH /api/v1/ai/budget` (service role). Mas o corpo do dump traz `GRANT ALL ON TABLE public.ai_budgets TO anon` **e** `TO authenticated`, a policy de escrita é `for all` com `fn_role_at_least(...,'admin')`, e a 0159 termina com `notify pgrst, 'reload schema'` — logo as colunas novas passaram a ser SERVIDAS pelo PostgREST para a chave anon, que vai ao browser: um PATCH direto na REST armava a parada sem escada, sem carência, sem piso e sem linha em `api_audit_log`. O comentário de coluna da 0159 ("escrito só por PATCH /api/v1/ai/budget") era verdade sobre o CÓDIGO e falso sobre o SCHEMA. `revoke insert, update, delete ... from authenticated, anon` — **SELECT fica** (a policy de SELECT da 0150 já escopa a leitura, e tirá-la quebraria clone que montou painel próprio na anon key). Medido antes de revogar: TODO escritor de `ai_budgets` no repositório usa service role (a rota, `lib/ai/budget/check.ts`, os painéis de admin, os workers e `scripts/qa-wave-11.ts`), então o revoke não quebra caminho de produto nenhum. `revoke` é idempotente por natureza, então o apêndice do baseline pode ser re-aplicado pelo `update.sh` à vontade. **NÃO MEDIDO contra um PostgREST vivo** (Docker indisponível na máquina em que foi escrita): o achado e o conserto vêm de leitura de grants e policy no `baseline.sql`. |
| `20260819140000` | `0161_outbound_zera_unread` | **Badge da inbox inflava e nunca caía após resposta.** `fn_mark_conversation_message` só incrementava unread em inbound; envio pelo CRM (`sendMessageHandler`) nem chamava a RPC — contador acumulava mensagens já respondidas. Outbound passa a zerar `unread_count_for_assignee`; envio CRM também zera na atualização da conversa; rota `POST .../mark-read` + hook `useMarkAsRead` (1,5s em foco) cobre leitura sem resposta. |
| `20260819150000` | `0162_contato_ultima_atividade_mensagem` | **"Última atividade" em /app/contacts ficava parada após mensagens.** A coluna `contacts.last_activity_at` só era tocada pelo trigger de `crm_lead_activities`; ingestão de WhatsApp/Meta/Zernio atualiza `conversations` via `fn_mark_conversation_message` sem carimbar o contato. A RPC passa a fazer `greatest` em `contacts.last_activity_at`; backfill a partir de `conversations.last_message_at`; envio CRM (`sendMessageHandler`) também carimba o contato (não usa a RPC). O relógio do lead segue na lista positiva da 0079. |
| `20260820010000` | `0163_o_arquivo_do_webhook_pode_perder_o_corpo` | **O arquivo do payload cru ganha retenção — e a coluna que já existia para isso ganha dono.** `webhook_events_log` é o único lugar onde o corpo do webhook fica guardado, e nunca foi podado. Medido numa instalação real em 20/08/2026: banco em **545 MB**, dos quais **468 MB (86%) eram esta tabela** — contra 3,2 MB de `messages`. Nenhuma linha com mais de 30 dias: as 56.291 eram de 20 dias, ~23 MB/dia, ~700 MB/mês, sem teto. O plano gratuito do Supabase acaba em 500 MB, que é onde a maioria dos clones vive. **Esvaziar, não apagar:** as três colunas pesadas (`raw_body`, `payload_parsed`, `headers`) são ~97% do peso e a linha sem elas custa ~200 B — apagar responderia ao espaço e mataria o instrumento, porque "quantos eventos de que tipo chegaram, quando, e a assinatura conferia?" é justamente a pergunta de depois do incidente (foi ela que decidiu o caso das identidades opacas contando "76 de 76"). O índice forense inteiro sobrevive por ~11 MB. **Por que `raw_body` passa a aceitar NULL:** `not null` está certo para quem ESCREVE, mas sem NULL não há como dizer "o corpo existiu e foi descartado" — string vazia se confunde com corpo vazio de verdade, que é caso real (ping). Medido antes de afrouxar: **nenhum leitor** consulta `raw_body` no repositório (os 7 aciertos são INSERT, e a única tela que lê o arquivo pede `payload_parsed`). **`archived_at` já existia e não tinha dono** — 0 linhas com valor em 56.350, promessa de esqueleto (anti-pattern nº 3); ganha dono aqui em vez de nascer coluna nova com o mesmo significado. Índice PARCIAL `where archived_at is null` porque ele encolhe conforme a poda avança. Aditiva e idempotente: afrouxar `not null` não pode ser violado por linha que já passava. |
| `20260820030000` | `0164_atribuicao_de_anuncio` | **De qual anúncio um contato do WhatsApp veio.** `contacts.source`/`source_metadata` já existiam (nasceram genéricos, `'whatsapp'`); esta migration não cria coluna, só `fn_estampar_atribuicao_de_anuncio(p_contact, p_platform, p_metadata)` — chamada pelo ingest de canal (WAHA e o canal oficial) quando a primeira mensagem de um contato novo traz dados de um clique em anúncio "Clique para o WhatsApp" da Meta. **Função, não UPDATE direto do aplicativo:** o merge de `source_metadata` (`||`, preserva `waha_lid`/`waha_chat_id`/`notify_name` que `fn_upsert_wa_contact` já gravou) e a guarda de PRIMEIRO TOQUE (`source_metadata->>'ad_platform' is null`) precisam ser UMA operação atômica — duas mensagens quase simultâneas do mesmo contato novo não podem correr a corrida de ler+mesclar+escrever em JS. **Primeiro toque, nunca sobrescreve:** clicar em outro anúncio meses depois, numa conversa já aberta, não reescreve de onde a pessoa veio ORIGINALMENTE — o UPDATE casa zero linhas, silenciosamente, quando já há `ad_platform` gravado. **Os dois revokes do item 9 do CLAUDE.md** (`from public, anon, authenticated` + `grant to service_role`): só o backend chama isto (admin client no ingest), nenhum papel de sessão precisa. Idempotente (`create or replace`) e sem backfill: não altera dado existente. |
| `20260820120000` | `0166_indice_do_claim_da_fila` | **O cap global do claim varria `job_queue` inteira a cada rodada.** `claimJobs` (`lib/agent-engine/queue/queue.ts`) abre toda rodada — dentro do advisory lock que serializa os claimers — com `select count(*) from job_queue where status = 'running'`, e nenhum dos quatro índices da tabela servia esse predicado. O parcial das lanes (`uniq_job_queue_one_running_per_contact`) chega perto e **não vale**: o predicado dele é mais ESTREITO (exclui `contact_id is null`, que é todo `watchdog`/`flywheel`), então o planejador não pode responder por ele. Medido em `pgvector/pgvector:pg17` com este baseline, 50.000 linhas `done` + 4 `running`: **Seq Scan / 715 buffers → Index Only Scan / 3 buffers**, índice de **16 kB**. **O custo não depende de linha viva, depende de bloat:** apagando as 50.004 dentro de uma transação e perguntando de novo, o Seq Scan ainda lê os **mesmos 715 buffers** — ele visita PÁGINA, não tupla —, e nada no produto poda `job_queue`. **O `/healthz` do worker NÃO é consertado por isto, e foi medido, não suposto:** `workers/agent-worker/main.ts` e `lib/agent-engine/obs/metrics.ts` fazem `select status, count(*) from job_queue group by status`, que precisa de TODOS os status; com o índice novo o plano continua HashAggregate sobre Seq Scan, 715 buffers, idêntico ao de antes — e um índice CHEIO em `(status)` também não move (medido: o planejador segue escolhendo Seq Scan, e o índice custaria 360 kB). Fica fora do escopo: aquilo roda em probe do Docker a cada 30s, não no caminho quente do claim. **O bloco `do $$` não é cerimônia:** `create index if not exists` casa por NOME e não por definição, e as duas variantes foram medidas em pg17 — homônimo na própria `job_queue` com outra definição vira `NOTICE: ... already exists, skipping` (NOTICE nem chega ao filtro `ERROR\|FATAL` do `update.sh`: no-op perfeitamente silencioso), e homônimo em OUTRA tabela (nome de índice é único por SCHEMA) dá o mesmo no-op **e** faz o `comment on index` acertar o índice errado, cegando o delator. O bloco derruba e recria o homônimo NOSSO em `job_queue`; o de outro objeto ele **não apaga** — `raise exception` com a razão escrita, que é o comportamento certo num script que roda sem `ON_ERROR_STOP` (o erro aparece ao operador e o resto do baseline segue). **O `comment on index` é DELATOR:** índice ausente levanta `relation "idx_job_queue_running" does not exist`, texto conferido contra a lista benigna real do `update.sh` (`already exists\|multiple primary keys\|multiple default values\|is already a member\|already a partition`) — não casa com nenhum termo, logo chega ao cliente; um `already exists` seria engolido. Guardado por `tests/invariants/queue-cap-global-do-claim.test.ts`, que **extrai o `select` do FONTE de produção** em vez de copiá-lo: índice presente e predicado mudado (`::text` a mais, `lower(status)`, `status in (...)`) devolveria Seq Scan com o símbolo intacto. Aditiva e idempotente: sem constraint, sem backfill, sem dado tocado. |
| `20260827180000` | `0196_followup_pointer_surface` | **Um motor, duas listas.** `followup_flow_pointers.surface` (`followup` \| `crm_automation`, default `'followup'`) recorta a lista de follow-up da IA da seção CRM Automação sem clonar tabela, cron nem canvas. CHECK de conjunto — par em `tests/invariants/vocabulario-banco-x-typescript.test.ts`. Aditiva e idempotente: `ADD COLUMN IF NOT EXISTS` + drop/add da constraint. Sem backfill: o default cobre linha já existente. |
| `20260827181000` | `0197_push_subscriptions` | **Web Push: inscrição por navegador.** Tabela `push_subscriptions` (endpoint + p256dh/auth), RLS da própria linha (`organization_id in fn_user_org_ids()` e `user_id = auth.uid()`), unique em `endpoint`. Sem ela o servidor não tem destino para o protocolo Web Push. Aditiva. `revoke` em `anon`/`public`; `grant` CRUD a `authenticated`. |
| `20260827182000` | `0198_nono_digito_canonico` | **Celular BR grava e mostra COM o nono dígito.** `+553284793302` e `+5532984793302` são a mesma pessoa; o CRM unifica na forma com o 9. `fn_upsert_wa_contact` busca as duas grafias, promove 12→13 quando o canônico está livre, e o backfill funde pares duplicados + reescreve quem só tinha 12 dígitos. Fixo (local 2–5) e estrangeiro não mudam. O envio ao WhatsApp/WAHA continua tentando as duas grafias via `check-exists`. Idempotente (`create or replace` + updates que na segunda passada casam zero linhas). |
| `20260820160000` | `0165_identificador_de_canal_unico_entre_ativos` | **Os dois identificadores de canal que chegaram depois do snapshot não tinham trava nenhuma, e é por eles que o código resolve credencial de envio e o DONO de uma mensagem que acabou de entrar.** `waha_session_name` e `webhook_path_token` são UNIQUE desde o snapshot; `meta_phone_number_id` (0087) e `zernio_account_id` (0131) nasceram sem. Traz **dois índices únicos PARCIAIS** (`where archived_at is null`), pelo precedente da 0107 — canal arquivado é canal excluído e a linha só sobrevive como âncora das FKs RESTRICT, então trava total impediria reconectar o mesmo número depois de excluí-lo. **O que a ausência produzia (issue #236):** três consultas de `lib/channels/` resolviam a sessão só pelo identificador, em client de service role (bypassa RLS). Com duas linhas casando, `maybeSingle()` **não** devolve "a primeira" — medido contra `@supabase/postgrest-js` 2.112.1, devolve `data: null` + `error PGRST116` (HTTP 406). Os três **descartavam o `error`**, então: os dois resolvedores de credencial caíam no fallback do `.env` (a mensagem saía pela conta de OUTRA instalação) e `meta/ingest.ts` devolvia `no_session` com a rota respondendo 200 — a mensagem recebida era **descartada para as duas organizações**. **Classificação: alta, não crítica** — a colisão é atingível por configuração LEGÍTIMA (agência, migração de conta entre organizações), não por reivindicação hostil: `validatePartnerCredentials` (`lib/channels/connect.ts`) exige que o identificador esteja na lista de contas que a chave informada alcança. **A deduplicação vem ANTES da trava e não é destrutiva:** o `update.sh` do clone roda sem `ON_ERROR_STOP` e engoliria o 23505, deixando o clone sem trava e sem aviso. Apagar está fora (sessão de cliente, histórico por FK RESTRICT) e arquivar faria o canal sumir da tela sem ninguém pedir — a perdedora é **RENOMEADA** para `<original>-conflito-<id da sessão>`: continua visível, e como o identificador não existe no provider a varredura de saúde (`app/api/v1/cron/channel-health`) grava `FAILED`/`STOPPED` na passada seguinte e **abre aviso na Central** (os três estados estão em `STATUS_QUE_AVISAM`). Fica com o identificador a sessão ativa **mais recente** (criá-la exigiu provar posse da conta na tela de conexão ⇒ é a intenção mais recente); errar o palpite não destrói nada e a linha perdedora ela mesma avisa. **Idempotente por construção:** o sufixo carrega o `id` (único), então a segunda passada casa zero linhas e não há como sufixar duas vezes. Nomes de índice novos (conferidos contra `baseline.sql` e `migrations/`), então o `if not exists` — que casa por NOME — não vira no-op em cima de um homônimo com outra definição. O código foi corrigido nas três camadas junto: filtro de `organization_id` nos três sítios (com o `organizationId` atravessando o seam de canal como campo **obrigatório**, para o typecheck cobrar), `error` que deixa de ser descartado, e o invariante `tests/unit/canal-consulta-por-organizacao.test.ts`, que varre `lib/channels/` derivando as colunas de `CHANNEL_SESSION_REF_COLUMNS` — o quarto canal entra na varredura sozinho. |
| `20260820170000` | `0167_poda_da_fila_e_expurgo_do_audit` | **Nada no produto apagava job terminal, e a retenção de 5 anos do audit existia só no COMMENT.** `grep -rn "from job_queue" lib workers app supabase scripts | grep -i delete` devolvia **zero linhas**: `job_queue` crescia desde a instalação e nunca encolhia; `api_audit_log` prometia 5 anos + "hot 90 dias / cold S3" em seis documentos, sem uma linha de código que executasse qualquer das duas metades. São as candidatas naturais a estourar os **500 MB** do plano free antes de qualquer tabela de negócio — e o bloat também custa CPU (715 buffers varridos no `count(*)` do claim com ZERO linhas vivas, medido na #260). Traz **duas `security definer`** (`fn_podar_fila_de_jobs`, `fn_expurgar_auditoria_vencida`), **três índices** e o cron `data-retention` (diário). **DELETE por idade e não particionamento**: particionar `job_queue` exigiria mexer no claim `FOR UPDATE SKIP LOCKED` e nos dois índices ÚNICOS parciais que garantem um turno por lead — trocar essa garantia por disco é péssimo negócio. **O QUE TEM DONO NÃO SAI, e são três cortes:** `pending`/`running` nunca saem (o primeiro ainda vai sair, o segundo está com um worker e o reaper o devolve); terminais são só `done`/`failed`/`dead` (conferidos em `lib/agent-engine/queue/queue.ts`); e **`dead` com aviso ABERTO na Central tem dono** — um humano que não olhou — com o `not exists` **antes** do `limit`, porque filtrar depois faria um lote de protegidos devolver 0, o laço do cron pararia achando que acabou e a poda morreria de fome com backlog na frente. **Cascata declarada:** o DELETE leva junto `send_ledger` e `before_send_traces` (FK `on delete cascade`, as duas também sem poda) e apenas anula o ponteiro em `llm_calls`/`lead_checkpoints`/`lead_state_transitions`; os dois consumidores de `send_ledger` sem janela (`countPriorAcceptedSends` → disclosure de IA, e o gate LGPD de 1º toque de prospecção, `before-send.ts:261`) falham **fechado** — disclosure a mais e veto a mais, nunca a menos —, daí piso de 7 dias e default de 90. **A definer do audit não é porta de adulteração, e cada razão é conferível:** (a) não tem seletor de linha — nenhum parâmetro de org, ator, ação ou id, e o único predicado é `created_at < now() - N dias`, então ela só sabe apagar pela ponta mais velha; (b) o **piso de 90 dias mora no corpo**, não em quem chama, então nem com a service key se remove rastro recente; (c) revogada das duas origens de EXECUTE e concedida só a `service_role`; (d) não amplia o raio de quem já tem a chave (`service_role` já tem `TRUNCATE` na mesma tabela); (e) **registra a própria erosão** — o cron grava `retention.sweep_run` com a contagem, e essa linha é nova demais para a chamada seguinte alcançar. `api_audit_log` **não tinha** GRANT de DELETE para ninguém (nem para `service_role`), e é por isso que o expurgo não podia sair pelo admin client. **Hot/cold em S3 não foi entregue e a doutrina do `CLAUDE.md` foi corrigida** em vez de fingir: o self-host não tem para onde arquivar (o Storage do cliente é a MESMA cota de 1 GB, já dividida com `whatsapp-media`). Índices com **nome próprio da poda** (`idx_audit_expurgo_created_at`) porque `create index if not exists` casa por NOME e um nome genérico viraria no-op silencioso num clone. Aditiva, sem constraint nova (nada a deduplicar antes), idempotente por `create or replace` + `if not exists` + `revoke`. **NÃO MEDIDO:** o comportamento sob milhões de linhas reais — os lotes foram exercitados no Postgres efêmero do `test:db`, não numa VPS com histórico de anos. |
| `20260821090000` | `0168_a_mensagem_que_responde_outra` | **A citação: qual mensagem esta responde.** O canal intermediado aceita `replyTo` no envio (recebendo o `wamid` da citada) e o WhatsApp mostra a resposta pendurada na original — que é como as pessoas conversam ali. Sem guardar QUEM foi citado, o CRM manda a citação para o cliente e não consegue mostrá-la de volta na própria tela: o atendente vê frases soltas onde o cliente vê um fio. **FK e não o wamid solto:** a pergunta da tela é "qual mensagem NOSSA foi citada?", e a resposta é uma linha desta tabela; guardar o texto do provider faria toda renderização buscar por coluna de texto e deixaria de funcionar para a citação de uma mensagem que ainda não tem `external_id` (a nossa, enquanto está `queued`). É o `R` da DIRC — a linha existe, aqui basta o ponteiro, e o id que o provider precisa sai dela no momento do envio. **`on delete set null`, nunca cascade:** apagar a citada não pode levar junto a resposta, que é conteúdo próprio de alguém; perder o fio é aceitável, perder a resposta seria apagar histórico por causa de um ponteiro. Índice PARCIAL porque a esmagadora maioria não cita ninguém. Aditiva e idempotente. |
| `20260824120000` | `0173_quem_manda_na_conversa` | **"Assumir" não calava o automático — dois atores atendiam o mesmo cliente.** Medido no HEAD 927dfa51: `grep -rn "assignee_kind\|assigned_to_user_id" lib/agent-engine/` devolve **rc=1** (zero acertos) e `fn_conversation_assign` nunca tocou `bot_silenced_until`. O motor moderno é dono da resposta sempre que a org tem agente publicado, e ele nunca soube que alguém assumiu: o automático só calava por **5 minutos deslizantes** quando o atendente ENVIAVA (`extendBotSilence`, `app/api/v1/messages/_handler.ts`). O guard existe desde a decisão G3-02 e só o worker LEGADO o implementa (`skip("assigned_to_human")`) — o motor regrediu sem ninguém notar, e é isso que faz qualquer selo de "você está no comando" ser mentira. **O conserto entra na função de atribuição, não no motor**, porque `bot_silenced_until` é o gate que o motor JÁ lê (`isLeadInHandoff`): nenhuma linha do motor muda. **A alternativa foi medida e REPROVADA:** ensinar o motor a ler `assignee_kind` parece a correção óbvia e produz mudez permanente — `Fechar` não solta o dono, de propósito ("quem atendeu é histórico"), então o fim NORMAL de um atendimento (Assumir→Fechar) deixa `assignee_kind='user'` pendurado; e como aquele gate é por CONTATO, ele calaria também conversa NOVA de OUTRO número do mesmo cliente (reproduzido em `pgvector/pgvector:pg17` com este baseline: gate atual `f`, gate com `assignee_kind` `t`, sobre uma conversa `closed`). **Três braços, e o do rodízio é o que impede a regressão silenciosa:** `p_reason='routing'` **não mexe** no silêncio — distribuir não é assumir, e `trg_conversation_routing_requested` dispara em TODA conversa nova com o worker rodando 1×/min, então sem a ressalva uma org em `round_robin` ficaria com o automático calado na PRIMEIRA mensagem da vida de cada cliente (medido), numa tela de configuração que não menciona IA; destino humano (`claim`/`transfer`) → `'infinity'`; destino nulo (`release`) → `null`. **Assinatura IDÊNTICA de 6 args de propósito:** parâmetro novo criaria OVERLOAD (o `create or replace` não substitui assinatura diferente) e as cinco chamadas por nome passariam a falhar com `is not unique` — medido. A limpeza do silêncio ao FECHAR mora na rota (`close/route.ts`), porque fechar não passa por esta função e sem ela o silêncio vazaria para o próximo episódio: a ingestão reusa a MESMA linha de conversa (`on conflict do update`). **De brinde, um furo de RLS que a mesma medição achou:** `cae_select` era membership de org PURA enquanto `conversations_select` passa por `fn_can_view_conversation` — org em `visibility_mode='own'`, agent que não é dono lia **0** linhas em `conversations` e **1** em `conversation_assignment_events` da mesma conversa, alcançável pelo PostgREST com a anon key + o JWT do usuário, sem depender de rota nossa. A policy passa a HERDAR o escopo no molde do `messages_select` (`exists` sobre `conversations`, que já aplica a RLS dela) em vez de reescrever a regra — duas cópias divergem na primeira mudança de uma delas. Idempotente (`create or replace` + `drop policy if exists`), sem constraint nova, sem dado a corrigir. |
| `20260825120000` | `0174_historico_de_leads_captados` | **Quem publica uma landing page não tinha como responder "chegou alguém, com que dados, de onde?".** A única coisa que existia era o ARQUIVO FORENSE (`webhook_events_log`), e ele é DESCARTÁVEL por desenho: o cron `webhook-log-retention` (a cada 5 min, migration 0163) **zera** `raw_body`/`payload_parsed`/`headers` em D+7 e **apaga a linha** em D+90. Foi a decisão certa — numa instalação real ele era 468 MB de um banco de 545 MB (86%), contra 3,2 MB de `messages` — mas transforma qualquer histórico construído sobre ele numa tela que MENTE a partir do sétimo dia: os campos viram `null` e nada na UI distingue "o formulário veio vazio" de "o corpo foi descartado ontem". Um arquivo de depuração e um histórico de negócio têm ciclos de vida OPOSTOS. Traz `webhook_lead_captures` com o que o arquivo não guardava: **o IP em coluna tipada** (`inet` — no arquivo ele só existia solto dentro do jsonb `headers`, que é uma das três colunas podadas em D+7); **o DESFECHO** (`criado`/`duplicado`/`recusado` + `reject_reason`) — o arquivo registra "chegou um POST" e não sabe dizer se virou lead, se caiu na deduplicação por `external_id`, ou se foi recusado por não ter campo mapeável, que é justamente o caso em que a pessoa hoje NÃO VÊ NADA (400 para o site dela, zero rastro na tela); e o **nome da fonte NO MOMENTO** da captação (cópia deliberada — a FK é `on delete set null` e o histórico responde de onde o contato VEIO, não de onde viria hoje; é o caso em que duplicar é a resposta certa da DIRC, porque o valor é um fato datado). **RLS exige `manager`, e não é cerimônia:** `webhook_lead_captures_manager_read` usa `fn_role_at_least`, enquanto a policy de `webhook_events_log` é org-flat sem gate de papel — hoje qualquer `viewer` lê a PII do formulário direto pelo PostgREST com a anon key, mesmo com a rota HTTP exigindo `manager`. Sem policy de INSERT/UPDATE/DELETE: só o service role escreve. **LGPD por TRIGGER, não por 9º passo:** `fn_lgpd_cascade_redact_contact` tem 180 linhas e acrescentar um passo exigiria reescrevê-la inteira no apêndice, criando duas cópias que divergem no primeiro conserto; o gancho é a transição `is_anonymized false → true` em `contacts`, que roda na MESMA transação do cascade e alcança QUALQUER caminho que anonimize um contato. **Sobre gravar IP:** `x-forwarded-for` é forjável e esta coluna NÃO é material de segurança — nada no produto decide com base nela; ela existe para o dono reconhecer padrão. No stack padrão do kit o header chega (o `app` não publica porta; quem publica é o Caddy, que faz `reverse_proxy app:3000`). Aditiva, idempotente, sem constraint sobre dado existente. |
| `20260825130000` | `0175_a_automacao_diz_a_verdade` | **A automação não tinha como dizer "ainda não".** `automation_rule_runs.status` aceitava `success`/`partial`/`failed`, e faltava o quarto estado que o motor JÁ produz: quando uma ação de envio pede adiamento (`postponeUntil` — fora da janela do número, cap diário atingido), `runAutomationForEvent` devolve `{status:'retry'}` e sai **sem gravar linha nenhuma**. O evento volta depois e, nesse intervalo, a aba Atividade não mostra absolutamente nada — para quem montou a regra, "não apareceu nada na Atividade" e "a automação não rodou" são a MESMA tela, e foi esse o relato que originou a mudança. É o invariante 4 do Sistema Vivo (nenhuma demanda sem próximo passo) aplicado a uma espera: a espera É um estado, e um estado que ninguém vê é indistinguível de morte. **Valor novo em vez de reusar `partial`:** `partial` significa "algumas ações funcionaram e outras falharam" e a tela pinta de amarelo com o texto "Parcial"; um adiamento não é falha nenhuma — nada foi tentado ainda, e vai ser. Empilhar os dois faria a tela mentir na direção oposta, assustando sobre uma mensagem que só espera o horário. CHECK reconstruído em **UM bloco só** (lição do #159 registrada no baseline para `agent_inbox_items_kind_check`: N blocos quebram o `update.sh` de clone com vocabulário posterior). Aditiva — só ALARGA o conjunto aceito, então nenhuma linha atual passa a violar e não há o que deduplicar antes. |
| `20260826140000` | `0181_o_acervo_e_da_organizacao` | **A base de conhecimento pertencia a um agente, e o acervo do agente era UMA versão monolítica.** Desses dois fatos saía quase todo o resto: material impossível de compartilhar entre assistentes; **UM documento por categoria por agente** (índice único `(agent_id, source_type) WHERE is_active`, e `upload/route.ts:186` grava `source_type='policy'` para TODO arquivo — o segundo PDF de qualquer organização colidia com 23505); **pipelines competindo pelo mesmo ponteiro** (`ingestConversationsBatch` cria a própria versão e chama `activate_kb_version`, que DESATIVA a versão de FAQ do mesmo agente, e o worker de FAQ faz o inverso — quem indexou por último apagava o acervo do outro, em silêncio); e apagar o agente apagava a base junto (FK CASCADE). **A inversão:** a fonte é da ORGANIZAÇÃO, `agent_id` vira histórico (nullable, `ON DELETE SET NULL`), e quem lê o quê é `ai_agent_versions.knowledge_source_ids uuid[]`. **Coluna na VERSÃO e não tabela de junção**, por duas razões medidas: o runtime lê a config do agente em UMA query sem cache (`agent-config.ts`, join pelo `published_version_id`) — junção custaria uma query a mais por turno atendido —, e escopo fora do ciclo rascunho→publicar muda o alcance do agente sem ninguém ter publicado nada (racional escrito da 0125, que esta migration copia inteiro, trigger de imutabilidade incluído). **O ponteiro de índice vira POR FONTE** (`ai_knowledge_sources.active_kb_version_id`), que é o que dissolve a competição por construção em vez de por disciplina. **As versões legadas continuam válidas sem backfill caro:** uma versão antiga contém chunks de VÁRIAS fontes e não há como atribuí-la a uma só — não é preciso, porque o predicado da busca é `chunk.kb_version_id = fonte.active_kb_version_id AND chunk.knowledge_source_id = fonte.id`, e uma versão compartilhada devolve para cada fonte exatamente os chunks dela; `knowledge_source_id` na versão fica nullable para sempre (nas novas é proveniência, nas antigas é honestamente desconhecido). **O CHECK de `source_type` sai** (precedente da 0127): ele tinha 6 valores com DOIS pares de sinônimos (`conversation`/`conversations`, `catalog`/`nuvemshop_catalog`) e nenhum valor para documento avulso; o vocabulário vai para `lib/ai/rag/tipos-de-fonte.ts` e a coluna fica fora do invariante `vocabulario-banco-x-typescript`, que só cobre colunas que JÁ têm CHECK. **A ordem não é detalhe:** o índice único `(agent_id, source_type)` cai ANTES do backfill de vocabulário, senão `catalog` e `nuvemshop_catalog` viram os dois `catalogo` e um agente que tivesse ambos colidiria no meio da migration — quebrando o `update.sh` do clone, que roda SEM `ON_ERROR_STOP`, em silêncio. Traz também: `last_index_status` aceita `indexando` e `sem_credencial` (os dois estados que o produto JÁ produz e a tela mostrava como "Não indexado", neutro); `arquivar` passa a desligar `is_active` de verdade (**nenhuma linha do repo jamais escreveu `is_active = false`** — medido); proveniência do embedding (`embedding_model`/`embedding_dims`) na versão, e a busca nova RECUSA a fonte indexada com outro modelo, porque vetores de modelos diferentes não são comparáveis e a busca continuaria respondendo com trecho errado e nota alta; as FKs que faltavam em `ai_agents.active_kb_version_id` e `ai_chunks.kb_version_id` (ponteiro pendurado era indistinguível de base vazia: zero chunk, zero erro); e `text-embedding-3-small` no catálogo `ai_models`, sem o qual o painel de provedores não tinha o que oferecer para os pontos de embedding. **RBAC de brinde e não por acaso:** as quatro tabelas de RAG ficaram de fora do aperto da 0150 e ainda tinham policy `ALL` só-tenancy mais `GRANT ALL ... TO anon` — um membro papel `viewer` DELETA a base da própria organização falando direto com o PostgREST, com o JWT dele; entram no par SELECT-tenancy + escrita-com-`fn_role_at_least` (`manager` nas duas que a tela edita, `admin` nas duas que só o motor escreve). Idempotente e auto-curativa: todo dado corrigido ANTES da constraint que o exige. |
| `20260827183000` | `0199_push_subscriptions_rbac` | **Forward-fix da 0197: `push_subscriptions` entrava com policy ALL só-tenancy+dono.** O gate `rbac-config-ia-canais` ("nenhuma tabela NOVA entra com policy ALL só-tenancy") reprovou no PR #346. A rota já exige `viewer`; a policy passa a espelhar com `fn_role_at_least(..., 'viewer')`, sem alargar quem escreve (continua só a própria linha). Idempotente (`drop policy if exists` + recreate). |
| `20260826190000` | `0177_agenda_o_compromisso_marcado` | **O produto sabia quando VOLTAR A FALAR, e não sabia que hora foi COMBINADA com o cliente.** "Agendar retorno" já existia — `cron_jobs` (`kind='at'`, `job_kind='followup_turn'`), escrito por `lib/followup/retorno-crm.ts` — mas retorno é decisão interna do sistema: não tem hora marcada com ninguém, não ocupa a agenda de ninguém e o cliente não sabe dele. O que faltava era o oposto: **duas pessoas combinaram estar juntas às 14h de quinta** — a consulta da clínica, a visita do corretor, a call da agência. Um dono de clínica que instalasse este produto marcava consulta no caderno. Seis tabelas: `calendar_event_types` (o molde: duração, buffers, aviso mínimo — mudar o molde não reescreve o passado), `calendar_appointments` (o compromisso), `calendar_availability_exceptions`, `calendar_connections` (a conta do Google, BYO), `calendar_connection_calendars` e `calendar_external_events`. Mais `user_organizations.calendar_color`. **NENHUMA tabela de jornada semanal, e é a decisão central:** ela já existe em `attendant_availability.schedule` — validada por `availabilityScheduleSchema`, lida pelo roteamento de conversa (`isWithinSchedule`) e com tela em `AttendantsClient.tsx`. Duplicá-la faria o dono de clínica configurar o horário do funcionário em DOIS lugares (anti-pattern nº 2). A agenda LÊ aquela coluna, com outra régua e escrita: `windows` vazio significa 24/7 para o roteamento e "não publicou horário" para a agenda, porque agenda 24/7 por omissão deixaria marcar consulta às 3h. `isWithinSchedule` não foi tocada. A ÚNICA tabela nova de disponibilidade é a de exceção por data, que é informação que o jsonb não sabe dar ("dia 12 não atendo", "neste sábado atendo"). **Sem coluna `lead_id`:** `crm_lead_links.target_kind` já aceitava `'appointment'` antes desta migration — o lugar do agendamento no polimórfico do CRM estava reservado, e uma FK própria seria um segundo mecanismo de vínculo para o mesmo fato (anti-pattern nº 8). Como `target_id` é polimórfico e não pode ter FK, o ON DELETE que falta vem de trigger: `fn_limpar_vinculos_do_agendamento` apaga o vínculo quando o agendamento é apagado. `contact_id` é FK real com **RESTRICT**, acompanhando `conversations.contact_id` e `messages.contact_id` — as duas únicas RESTRICT do schema, e pela mesma razão: apagar contato não pode apagar histórico. **`start_minute`/`end_minute` NOT NULL com default (0/1440) em vez de nullable**, porque numa UNIQUE o NULL não colide com NULL: dois "dia 12 bloqueado o dia todo" para a mesma pessoa passariam os dois, em silêncio. Dia inteiro é (0,1440), que colide como deve. **Sem constraint de sobreposição de horário:** exigiria `btree_gist`, medido ausente tanto no baseline (que só cria `pgcrypto`) quanto no prelude de `scripts/test-db.sh` — a constraint quebraria o `install` de todo clone. Quem impede overbooking acidental é o motor de slots, onde a regra pode ter exceção; o banco guarda o fato. **Vocabulários espelhados, não inventados:** `calendar_connections.status` são os SETE valores de `tenant_integrations_status_check` (incluindo `rate_limited`, que é o estado que uma API de calendário mais produz); `created_by_kind` segue `crm_lead_activities.actor_kind` (`user`/`ai`/`system`/`contact`) em vez do par `human`/`agent` da outra convenção do repo, porque é na timeline do lead que essa autoria é renderizada — duas palavras para a mesma pessoa seria a tela mentindo; `+ sync`, que não é ator do produto e sim "a linha nasceu de evento que já estava na agenda externa". Cada CHECK de vocabulário é uma constraint DEDICADA (a regra de negócio do cancelamento vai em constraint separada), porque duas constraints casando `col in (...)` na mesma coluna fazem o extrator do invariante de vocabulário se recusar a escolher. **RLS:** cinco org-flat com `fn_user_org_ids()` + `fn_is_platform_admin()` — a agenda de uma clínica é de quem trabalha nela, e o requisito é filtro POR pessoa, não sigilo ENTRE pessoas. `calendar_connections` é a exceção e leva gate: lê o dono da conexão ou `manager`+, porque guarda token OAuth e o PostgREST também serve a tabela com a anon key. Escrita dela não tem policy nenhuma: quem grava token é o callback e o worker de renovação, ambos com service role. Todas com `revoke all ... from anon`. Aditiva e idempotente: seis tabelas novas e uma coluna nova nullable. Nenhuma linha atual passa a violar nada — não há o que deduplicar antes da constraint. |
| `20260826200000` | `0182_dois_cliques_nao_marcam_duas_vezes` | **Entre a validação do motor de slots e o INSERT há uma janela, e dois POSTs cabem nela.** O motor decide se o horário está livre ANTES de gravar; entre aquela pergunta e o INSERT o banco não repete a pergunta, então dois POSTs simultâneos para o mesmo horário passam OS DOIS e criam dois agendamentos. É o duplo clique da recepcionista e a corrida entre duas pessoas marcando o mesmo slot — o acidental que o motor NÃO vê, porque ele validou e estava certo quando validou. **Não é a constraint de sobreposição que a 0177 recusou, e a distinção é o que faz esta passar onde aquela não passava:** `exclude using gist` proibiria SOBREPOSIÇÃO (14h-15h contra 14h30-15h30, que é o encaixe que uma recepção faz todo dia) e exigiria `btree_gist`, medido ausente no baseline e no prelude de `scripts/test-db.sh` — quebraria o `install` de todo clone. Índice único parcial é **btree puro** e proíbe só a COINCIDÊNCIA EXATA de instante para o mesmo dono. **`owner_user_id is not null` NÃO conserta buraco, e eu afirmei que sim antes de medir.** Medido num pg17 descartável, os dois índices lado a lado: com a condição e sem ela, duas linhas com dono NULL no mesmo instante entram IGUAL — `NULL` nunca colide com `NULL` numa UNIQUE, esteja a linha dentro ou fora do índice; o controle com dono real recusa a segunda com `23505`. A condição fica por duas razões reais: mantém fora do índice o que nunca colidiria, e DECLARA o alcance da guarda (ela é sobre a agenda de uma PESSOA, e agendamento sem atendente não ocupa a de ninguém). É documentação executável, não proteção. **Distinta do caso da 0177**, onde `start_minute` virou `NOT NULL` e ali o comportamento muda de verdade — o valor passa a existir e a colidir. **O custo, escrito para ninguém descobrir sozinho:** proíbe dois compromissos do mesmo atendente no mesmo instante, e numa clínica isso às vezes É o encaixe deliberado. A troca é consciente, mas o `23505` não pode virar erro genérico: a rota devolve 409 dizendo QUAL compromisso está ali e oferecendo o caminho — invariante 4 do Sistema Vivo. Sem isso troca-se uma corrida rara por uma parede diária. **Deduplica ANTES da constraint, e deslocando em vez de apagar:** índice único falha se os dados já o violam, e num clone isso quebraria o `update.sh`, que roda sem `ON_ERROR_STOP` e filtra erro por texto. O bloco empurra a 2ª/3ª ocorrência em 1 segundo cada, levando `ends_at` junto para a duração não mudar, em laço com teto de 10 passadas. Cancelar a duplicata apagaria um compromisso combinado com uma pessoa real, e isso uma migration não faz. Nenhum clone tem linha nesta tabela hoje — nasceu na 0177 e a rota que grava não existe. |
| `20260826210000` | `0183_a_grade_da_agenda_nao_se_move_sozinha` | **A grade não atualizava sozinha, e o diagnóstico ia para o lugar errado.** `calendar_appointments` não estava na `publication supabase_realtime`: o `.channel()` sobe, o `subscribe` devolve `SUBSCRIBED`, **nenhum erro em lugar nenhum**, e nenhum evento chega nunca — duas pessoas com a agenda aberta não veem o que a outra marcou, e a tela parada é indistinguível de "ninguém marcou nada". **O agravante é de diagnóstico:** nesta base o canal já morre calado por OUTRO motivo quando o token não chega ao socket, então quem investigar vai direto para o `setAuth`, onde o defeito esteve antes, e não para a publicação, onde ele está agora — dois defeitos com o MESMO sintoma fazem o segundo custar o dobro. **Só `calendar_appointments` entra, e a doutrina julga tabela a tabela** (as palavras são do próprio schema: `crm_lead_scores` ficou fora porque "recálculo é telemetria e não deve pintar card"): `calendar_external_events` é espelho reescrito em lote pelo sync, e 200 eventos do Google virariam 200 pulsos — o "pulso que mente" da 0075 em forma de calendário; `calendar_connections` guarda token OAuth; as de configuração mudam com quem já está na tela que recarrega. **O que esta migration NÃO resolve, declarado para não ser descoberto em produção:** `replica identity` tem ZERO ocorrência neste schema, então o payload de DELETE traz só o `id` — um canal que assine com `filter: owner_user_id=eq.<uuid>` **não recebe o DELETE**, e o card do compromisso apagado fica na tela até o F5. As tabelas já publicadas convivem com isso porque seus consumidores invalidam a query inteira em vez de aplicar o payload. Não liguei `replica identity full` porque hoje não existe assinante — `app/app/agenda/_client.tsx` tem zero ocorrência de realtime —, e o custo em WAL seria para servir um consumidor inexistente; a decisão de COMO a tela lida com o DELETE é de quem escrever o hook. Aditiva e idempotente, com a guarda de `pg_publication_tables` que o próprio baseline usa. |
| `20260826220000` | `0184_anonimizar_um_contato_deixava_a_agenda_legivel` | **Anonimizar um contato reportava SUCESSO e deixava a queixa clínica legível.** `fn_lgpd_cascade_redact_contact` percorre uma lista de tabelas escrita à mão e `calendar_appointments` não estava nela — e ela guarda, em texto livre, `title` ("Consulta — Maria Silva"), `description`, `notes` (a anotação do atendimento, que numa clínica é queixa clínica), `location_details` (o endereço de uma visita) e `cancellation_reason`. **O que torna isto grave não é o esquecimento, é a FORMA do silêncio:** a função devolve contagem por tabela, a rota reporta sucesso, o SLA de D+15 é marcado como cumprido — nada erra, nada loga, e o titular recebe a confirmação de que seus dados foram anonimizados enquanto a queixa dele continua legível no banco, com hora e endereço. E o `on delete restrict` de `contact_id` **não protege nada aqui**, porque a LGPD deste produto ANONIMIZA em vez de apagar: o contato vira "Cliente Anonimizado #N" e as linhas da agenda seguem intactas. **Trigger, e não um passo dentro da função:** ela vem do `pg_dump` com ~180 linhas, e acrescentar um passo exigiria carregar uma CÓPIA inteira dela no apêndice — duas cópias que divergem no primeiro conserto de qualquer uma. O gancho em uso é `after update of is_anonymized on contacts`, o mesmo da 0174, e ele roda na MESMA transação do cascade. **E há uma razão que vale mais que a economia de linhas: o trigger escuta a COLUNA, não o chamador**, então alcança qualquer caminho de anonimização — o que importa porque existe mais de um neste repo. **Redige o texto livre e PRESERVA `starts_at`, `ends_at`, `status`, `event_type_id` e `owner_user_id`:** a doutrina manda preservar timestamps, e a razão vale aqui — a clínica precisa responder "quantos atendimentos houve em março" depois de anonimizar, e isso é registro de operação, não dado pessoal. O QUE aconteceu e QUANDO fica; COM QUEM e SOBRE O QUÊ sai. **`calendar_external_events` fica de FORA, e não por esquecimento:** ela não tem `contact_id`, e o único vínculo com a pessoa é o `title` copiado do Google — não há predicado que a alcance a partir do contato anonimizado. Alcançá-la exige decidir entre declarar por escrito que é espelho de sistema de terceiro (e então o EXPORT precisa dizer isso ao titular) ou apagar a janela inteira daquela conexão. Registrado como pendência com dono, não como omissão silenciosa. Aditiva: função e trigger novos, nenhuma linha existente muda ao aplicar. |
| `20260826230000` | `0185_instalacao_fresca_nao_dava_para_marcar_nada` | **Instalação fresca abria a Agenda e não dava para marcar nada.** Zero `INSERT` em `calendar_event_types` em todo o repo — medido em `lib/`, `app/`, `scripts/` e `supabase/`, com controle positivo (a mesma sonda contra `crm_stages` acha 3 lugares no SQL e 31 no TypeScript). O vocabulário de categorias existia desde a 0177 e ninguém escrevia linha nenhuma. **E não dava erro:** a grade vazia é indistinguível de "ninguém marcou hoje" — quem instala numa VPS abre a Agenda, vê uma semana em branco e não tem o que clicar, sem mensagem e sem próximo passo. É o caso P0 da doutrina de QA Visual e o item 7 do pedido do dono do produto. **TRIGGER e BACKFILL, e não um só:** medido, o baseline NUNCA semeia organização que ainda não existe — o que ele faz é backfill das de hoje, e o único mecanismo que alcança organização FUTURA é trigger em `organizations` (há exatamente um no schema, o do funil). Só backfill deixaria a segunda organização criada amanhã com a agenda vazia; só trigger deixaria sem nada todo clone que já instalou. **NEUTRO de propósito, e é decisão, não preguiça:** três tipos que servem a qualquer negócio (Consulta, Reunião, Atendimento). O nicho não é persistido em lugar nenhum — `escolherPacotePorTexto()` roda em memória no passo do funil e o resultado morre ali, e o que fica em `onboarding_state.funil` é `{pipeline_id, origem, etapas}`, sem o id do pacote. Além disso o trigger dispara no INSERT da organização, antes de existir texto para inferir: o nome é tudo o que há e `welcome.o_que_faz` ainda não foi preenchido. Este é o PISO, não o teto — o enriquecimento por nicho, que é o que o item 7 pede de verdade, vive onde o nicho EXISTE, em `app/actions/onboarding/montarQuadro.ts`, e entra em commit próprio. Os dois não competem. **`on conflict do nothing`, nunca `do update`:** o `update.sh` re-aplica o `baseline.sql` inteiro a cada atualização, e com `do update` o tipo que o dono JÁ editou — renomeado, com outra duração — seria sobrescrito a cada versão do produto, em silêncio. É a diferença entre semear e mandar. **`default_owner_user_id` fica NULL** e é deliberado: o trigger dispara no INSERT de `organizations` e o `bootstrap-owner.ts` só escreve em `user_organizations` depois — não há a quem apontar, e a FK é `on delete set null`. Aditiva e idempotente: funções novas, trigger novo e backfill guardado por `not exists`. |
| `20260827000000` | `0186_a_cor_da_pessoa_e_uma_trilha_nao_um_hex` | **A 0177 criou duas colunas de cor guardando hex, e as duas estavam erradas.** O argumento que as derruba é do @VPS, escrito no cabeçalho de `components/agenda/paleta.ts`: hex guardado é "um segundo lugar para a mesma verdade, e o tema escuro fica de fora". Medido: as cores vivem em `app/globals.css` como `--agenda-pessoa-1..8`, em TRÊS blocos de tema, e a MESMA trilha tem hex diferente em cada um — a trilha 1 é `#ac4d40` num bloco e `#f89080` noutro. Um hex no banco não tem como ser as duas coisas: nasce sem tema escuro, e a tela ou ignora o valor do cliente ou perde o tema. **Por que AGORA:** zero consumidores das duas colunas, medido com controle positivo (a mesma sonda acha `trilha` em 34 arquivos, então estava viva). Trocar hoje custa esta migration; trocar depois que a rota de marcar gravar custa migração de dado de cliente — a janela fecha quando o POST nascer. **`calendar_color` vira `calendar_trilha` (smallint 1..8) e a escolha manual FICA:** `trilhaPadraoDoMembro()` deriva uma trilha estável do `user_id`, mas a derivação COLIDE para alguns pares (oito trilhas, mais de oito pessoas) e quem administra vai querer desempatar. NULL = use a derivada. **`calendar_event_types.color` SAI, e não é só por falta de consumidor:** há UM pixel por compromisso na grade, e duas colorações competindo pelo mesmo lugar significam que uma delas mente — ou a faixa diz de quem é o compromisso, ou diz que tipo ele é, e o olho não lê as duas. O item 10 do pedido diz cor POR PESSOA. Se um dia alguém quiser colorir por tipo, volta como trilha também, com um alternador que torne as duas mutuamente exclusivas. ⚠️ **`DROP COLUMN` é destrutivo e não foi escrito de leve.** O que autoriza: as colunas nasceram na 0177 no mesmo dia, o seed da 0185 não preenche nenhuma das duas, e a varredura por consumidor devolveu zero em `lib`, `app`, `components`, `hooks`, `workers` e `tests`. Não há dado de cliente a perder porque não existe caminho que grave. |
| `20260827010000` | `0187_o_espelho_do_google_e_cache_com_prazo` | **Espelho declarado sem prazo vira arquivo permanente com outro nome.** A 0184 deixou `calendar_external_events` FORA da cascata de LGPD por ela não ter `contact_id` — o único vínculo com a pessoa é o `title` copiado do Google, e apagar por conexão destruiria dado de terceiros que não pediram nada. A decisão de produto foi declarar ESPELHO: a fonte da verdade é a agenda do Google do próprio cliente, onde o titular exerce o direito com o controlador de lá. **Mas essa declaração só é honesta com TRÊS propriedades**, e a terceira faltava: (1) reconstruível — o sync repõe; (2) some quando a conexão sai — já verdade, `connection_id` é `on delete cascade` desde a 0177; (3) **tem prazo**. Sem a terceira, "espelho" é só um nome mais simpático para um arquivo permanente de compromissos de terceiros, guardado por um produto que declarou não ser o controlador daquele dado. **Corta por `ends_at`, NUNCA por `created_at`:** um compromisso futuro não envelhece por mais antigo que seja o registro dele, e apagar pelo `created_at` — que é o que a poda de auditoria faz — removeria um evento marcado com um ano de antecedência antes de ele acontecer, fazendo a agenda marcar em cima de hora ocupada. **Piso de 7 dias e não os 90 da auditoria:** auditoria é rastro que existe para ser consultado depois de um incidente, e piso alto impede que o knob vire apagador de rastro; este espelho é cache, e quem quiser mais passado pede ao sync. Piso alto aqui não protegeria ninguém — só guardaria mais tempo dado de terceiro. Ligada ao cron `data-retention` que já existe, no mesmo molde das outras duas podas, com knob `CALENDAR_MIRROR_RETENTION_DAYS`. E `houveEfeito()` passou a contar a terceira: sem isso, uma rodada que só podasse o espelho apagaria linhas sem deixar registro — e a doutrina do repo é auditar QUANDO HÁ EFEITO, nunca parar de auditar. Aditiva: função e índice novos, nenhuma linha apagada pela aplicação da migration. |
| `20260827040000` | `0190_o_mesmo_state_do_google_valia_duas_vezes` | **O `state` do OAuth do Google valia duas vezes dentro do prazo.** Ele é assinado com HMAC e expira em dez minutos, mas o nonce era emitido e jogado fora — dívida declarada em `lib/agenda/google/estado.ts` desde que o arquivo nasceu, e provada POR EXECUÇÃO na verificação independente. Entra `calendar_oauth_nonces`, com o nonce como chave primária: a segunda tentativa viola a unicidade, e é assim que o replay é recusado. **TABELA e não Redis, e a razão é falhar fechado:** Postgres é o único armazenamento garantido em TODA instalação e o Upstash é opcional no self-host — propriedade de segurança que degrada em silêncio onde a dependência opcional falta é pior que propriedade nenhuma, porque a instalação sem Redis PARECERIA protegida. **Fecha replay e SÓ:** a outra porta, um `state` válido apresentado por outra pessoa, é fechada pela leitura da sessão no callback. São portas diferentes, e durante horas a dívida do nonce deu a impressão de cobrir as duas. Traz junto `fn_expurgar_nonces_de_oauth`, quarta poda do cron `data-retention`, com piso no corpo e os dois `revoke` que função nova em `public` exige. Sem ela a tabela cresceria para sempre — uma linha por conexão tentada, num produto que ninguém monitora. |
| `20260827030000` | `0189_o_espelho_nao_se_limpa_sozinho` | **A poda por prazo não fecha o fantasma, e quem ler a 0187 vai presumir que sim.** Ela deu prazo ao espelho e o `comment on table` passou a dizer "cache com prazo" — está certo e é insuficiente. **O caso que a poda NÃO alcança:** um evento com `ends_at` no FUTURO, de uma conexão VIVA, que foi apagado no Google. Ele nunca envelhece, porque o corte é `ends_at < now() - N dias` e futuro não vence nunca. Fica no espelho para sempre, ocupando um horário que na agenda do cliente já está livre — e o sintoma é o oposto do que a poda protege: em vez de dado velho demais, é dado que deveria ter sumido e não some, fazendo a agenda **RECUSAR uma hora que existe**. Quem limpa isso é a RECONCILIAÇÃO do sync (remover o que não veio na resposta da janela), que é da frente do Google e não existe hoje. Não é defeito da 0187 — é o LIMITE dela, e o limite precisa estar escrito onde a promessa está, senão a promessa engana. Ressalva levantada pelo maestro. **Por que merece migration em vez de linha de doc:** o `comment on table` é o que um DBA lê no `\d+` e é a única declaração que viaja junto com o schema para todo clone; uma ressalva que fica só no briefing morre com a entrega. Aditiva: só reescreve um comentário, nenhuma linha de dado é tocada. |
| `20260827020000` | `0188_a_volta_do_google_sem_identidade_cria_fantasma` | **A volta do Google não tinha identidade, e sem ela o mesmo compromisso bloqueia dois horários.** `calendar_appointments.google_ical_uid` existe desde a 0177 — o lado de IDA sabe qual evento do Google é nosso. A linha de VOLTA não guardava equivalente: `external_event_id` é o id do Google, não a nossa identidade. Medido: `calendar_external_events` tinha 14 colunas e nenhuma de uid, nem no `create table` nem em `alter` posterior. **Duas consequências já pagas:** (1) um agendamento nosso que a pessoa MOVE no Google volta pelo sync e ocupa o horário NOVO, enquanto `calendar_appointments` segue ocupando o ANTIGO — o mesmo compromisso bloqueando dois horários, sem nada que os ligue para desfazer; (2) o cron do "aconteceu?" não consegue perguntar se o evento foi cancelado do lado de lá a não ser por heurística de mesmo dono e mesma janela, cujo falso positivo é DESTRUTIVO — cancelaria um compromisso real porque outro evento na mesma janela foi cancelado. O passo está barrado no handler esperando esta coluna. **Destrava também o anti-eco:** `ehIcalUidNosso` deixa de ser função pura sem consumidor e passa a filtrar na escrita, para não reimportarmos como alheio o evento que nós mesmos criamos. Aditiva e idempotente: coluna anulável, sem default e sem constraint, mais índice parcial em `(organization_id, ical_uid) where ical_uid is not null`. |
| `20260827050000` | `0191_o_playbook_de_agendamento_nao_conhecia_as_ferramentas` | **O playbook semeado mandava o CONTRÁRIO da ferramenta, e os dois chegam ao modelo na mesma janela.** O corpo de `agendamento` vem da 0069, anterior às ferramentas de agenda: no passo 5 ele manda "cancele/substitua o anterior", enquanto a `description` de `crm_reschedule_appointment` diz que remarcar é o MESMO compromisso mudando de hora. `serializeStablePrefix` serializa `tools` E `system` juntos — o `system` é o corpo do playbook —, então não há como prever qual vence, e o gatilho que injeta esse playbook é a própria palavra "remarcar". Corpo novo (98 linhas, 5507 caracteres) com a Regra de ouro virando critério verificável (você tem acesso *se e somente se* `crm_find_free_slots` existir — não julgue por intuição, chame), passo 2 condicionado à ferramenta, passo 5 resolvido nos DOIS ramos, e a separação entre marcar consulta e agendar retorno. **Nenhum ramo foi apagado:** o playbook é lido por quem tem as ferramentas e por quem não tem, e no dia seguinte ao merge a maioria não tem; o que muda é que o agente deixa de ADIVINHAR em qual ramo está, e o ramo sem ferramenta passa a declarar o custo que sempre teve ("você pode receber dois avisos"). **A mecânica é o que esta migration tem de diferente, e ela NÃO copia a 0069.** Aquela publica sob `if not exists (select 1 from skill_pointers ... name=...)`, que é correto para SEED e é o pior desfecho possível para PUBLICAR VERSÃO: todo clone instalado já tem o ponteiro, o bloco vira no-op, a migration passa VERDE e ninguém recebe o corpo novo — o defeito que este projeto já pagou ("o prompt vem da versão publicada"). Medido num pg17 efêmero: aplicar o corpo novo na forma da 0069 sobre um banco que já tem a 0069 deixa 1 versão e o ponteiro no corpo ANTIGO, **sem erro nenhum**. Aqui a idempotência é por CONTEÚDO (md5 do corpo, porque `skill_versions` não tem índice único e o `update.sh` re-aplica este baseline a cada atualização), o md5 declarado é CONFERIDO contra o que acabou de ser inserido (`raise exception` se divergir, em vez de uma segunda linha silenciosa), e o repointe roda SEMPRE — update, insert só se não achou linha. Provado: 0069 → 1 versão/ponteiro antigo; este bloco → 2 versões/ponteiro NOVO; 2ª e 3ª aplicação → segue 2 versões e o mesmo ponteiro. **A forma `values (null, '<nome>', ..., $body$...$body$, ...)` não é estilo:** `tests/unit/playbook-cita-a-ferramenta.test.ts` extrai os corpos semeados por esse padrão exato, e trocar o delimitador faz o gate ficar verde por vacuidade — sabotado e medido. Aditiva: nenhuma linha existente é apagada, a versão antiga continua em `skill_versions` (imutável por trigger) e só o ponteiro anda. |
| `20260827080000` | `0193_a_conexao_do_google_sem_fk_e_sem_fuso` | **Duas ausências da 0177, achadas por varredura e não por leitura — e as duas aparecem como linha AUSENTE, não errada.** (1) `google_connection_id` era a ÚNICA das nove colunas-ponteiro de `calendar_appointments` sem `references`: as outras oito trazem FK com `on delete` explícito **e** comentário justificando. Anti-pattern nº 4 do CLAUDE.md, e hoje sem custo porque o escritor de ida ainda não nasceu — custa no dia em que nascer, e aí o órfão é construtível. `set null` e não `cascade`: conexão do Google revogada NÃO apaga compromisso, que existe no CRM por direito próprio; apagá-lo seria cascade fantasma (anti-pattern nº 7) destruindo histórico por causa de uma integração. (2) O fuso vinha do Google, era gasto como metadado de auditoria e DESCARTADO por não haver coluna: o sync cravava `fuso: null` e o fallback `?? 'UTC'` disparava SEMPRE. Em `America/Sao_Paulo` um evento de dia inteiro bloqueia das 21h do dia anterior às 21h do seguinte — a noite do próprio dia vaza. A coluna vai em `calendar_connection_calendars` e não em `calendar_connections` porque o Google devolve `timeZone` POR CALENDÁRIO: guardar na conexão achataria N em 1, e uma conexão com dois calendários em fusos diferentes passaria a mentir sobre um deles. O comentário da coluna manda tratar NULL como "não sei", nunca como UTC — foi exatamente o `?? UTC` que produziu o vazamento. **Por que forward-fix em vez de editar a 0177:** ela já vive em QUATORZE branches com o baseline aplicado, e o `create table` do baseline é `if not exists` — banco já criado não recebe coluna por reescrita do create, o statement vira no-op. Editar a 0177 não alcançaria nenhuma das quatorze; o ponto sem volta desta mudança passou quando ela entrou na integração, não quando entrar na `main`. Aditiva e idempotente: colunas anuláveis, constraint sob guarda de `pg_constraint`, e backfill que anula ponteiro morto ANTES de criar a FK — senão o `update.sh` de um clone adiantado quebraria no meio, e ele roda SEM `ON_ERROR_STOP`. O apêndice entra ANTES do bloco da VARREDURA anon: não cria função e não precisaria da cura, mas apêndice depois dela recria a erosão que a 0192 acabou de consertar. |
| `20260827090000` | `0194_lembrete_nasce_desligado` | **O lembrete nascia LIGADO, e quem escolheu foi o default.** `reminder_enabled` veio `default true` na 0177. Medido: ZERO leitores fora do `database.types.ts` (idem `reminder_minutes_before`), e o `scheduler` não tem cron de lembrete — controle da mesma sonda: `event_type_id` aparece em 9 arquivos, então o instrumento enxerga colunas desta entrega sendo lidas. **O perigo não é o envio de hoje — não há envio. É a ORDEM DOS EVENTOS:** no dia em que o disparador nascer, ele lê a coluna, e toda linha criada antes, em toda instalação, já estará `true`. A chave chega PRÉ-LIGADA para o histórico inteiro, escolhida pelo default e não por gente. Enviar mensagem é irreversível e nunca é operação comum. "Não há quem dispare" é razão para não ser urgente, nunca para não ser defeito — e é o que torna a correção barata: uma palavra agora, contra data migration sobre linhas que o operador já mexeu. O `update` das linhas existentes só é seguro AGORA, por não haver leitor; depois seria apagar escolha alheia. Ligar por padrão volta a ser decisão do dono quando o disparador existir. Achado por varredura do QA; a premissa que eu havia levantado ("instalar a agenda dispara WhatsApp") era FALSA e foi ele quem a derrubou com número. |
| `20260827100000` | `0195_tipo_semeado_nascia_sem_dono` | **Os três tipos que o produto semeia nasciam sem dono, e sem dono não há agenda.** `fn_semear_tipos_de_agendamento` nunca definia `default_owner_user_id`, e a consulta de horários EXIGE dono — sem ele devolve `sem_responsavel`. Medido no caminho real pela cerca `agenda-marcar-pela-tela`: 422 três vezes, "Atendimento não tem responsável definido". Toda org nova nascia com três tipos decorativos: o usuário abre a Agenda numa instalação fresca, clica em Novo agendamento e não há horário. Nunca. **O seed não podia resolver sozinho:** o trigger é `after insert on organizations`, e naquele instante `user_organizations` ainda está vazia para a org — não há dono a escolher; a função não estava errada, estava CEDO. A saída é o outro lado do tempo: adotar quando o PRIMEIRO membro chega, e só o primeiro — se preenchesse a cada membro novo, um tipo que o operador deixou sem dono de propósito voltaria a ganhar um, e o produto desfaria escolha de gente. `revoked_at is null` nas duas metades do backfill: a tabela guarda o ex-membro em vez de apagá-lo, e sem o filtro o dono padrão da agenda poderia ser alguém que já saiu. Aditiva: função nova, trigger novo, e backfill que só toca linha com dono NULO. Achado pelo QA ao escrever a cerca; a metade da tela é dele (b41c66ea). |
| `20260827190000` | `0200_o_que_ainda_nao_foi_ao_google` | **O worker que empurra compromisso para o Google nunca empurrou nada — em instalação nenhuma.** `agenda-google-push` pedia os pendentes com `.or("google_synced_at.is.null,updated_at.gt.google_synced_at")`, e o PostgREST trata o lado DIREITO de `gt.` como VALOR LITERAL: ele tenta converter a string `"google_synced_at"` em `timestamptz` e recusa a consulta INTEIRA. Medido no log de produção da v1.7.0: `invalid input syntax for type timestamp with time zone: "google_synced_at"`, um `warn` a cada 5 minutos desde o deploy, zero linhas lidas. Não era "o filtro pegava demais" — era nenhuma linha, nunca. **O teste unitário ficou verde porque guardava a CHAMADA, não o EFEITO:** o dublê aceita qualquer string de filtro, e uma string que o Postgres recusa é indistinguível de uma que ele aceita. A coluna gerada dá ao PostgREST um filtro que ele sabe fazer, e vem com a cerca que faltava — um invariante que roda contra Postgres REAL e reprova filtro cujo lado direito é nome de coluna da própria tabela. Escolhida sobre RPC porque o valor é derivado de duas colunas da mesma linha (mesmo argumento de `contacts.wa_identity`) e porque RPC nova traria a obrigação de revogar `execute` das duas origens. **Segura porque foi medida:** os 11 sítios que tocam `calendar_appointments` foram lidos e nenhum faz upsert de linha inteira — este repo já quebrou escrevendo em coluna `GENERATED`. Aditiva: coluna nova e índice parcial, sem dado a curar. |
| `20260827200000` | `0201_credencial_do_google_pela_tela` | **Conectar o Google Agenda exigia SSH na VPS e editar o `.env`.** As credenciais do app OAuth tinham UMA fonte: `process.env`, lida no boot. O produto é self-host para quem NÃO programa — nomear `GOOGLE_CALENDAR_CLIENT_ID` para essa pessoa é o mesmo que dizer que a funcionalidade não existe. Singleton de INSTALAÇÃO e não de organização, com argumento: o `redirect_uri` sai de `NEXT_PUBLIC_APP_URL`, o `install.sh` grava o par no `.env` da VPS, e o app OAuth é registrado no console do Google pelo dono da instalação — é uma VPS por cliente. Clone declarado do molde de `platform_branding` (0155), inclusive na proteção: **RLS ligada com ZERO policies** e grants revogados de `anon`/`authenticated`, então o PostgREST não serve a tabela. Isso não é excesso — a anon key vai para o browser, e o `client_secret` permite trocar códigos e refresh tokens em nome da instalação, isto é, ler a agenda de todos os atendentes que conectaram. Reusa `fn_encrypt_oauth`/`fn_decrypt_oauth` (0041), a MESMA cifra que o callback do Google já usa para os tokens: nenhuma função nova em `public`, logo nenhuma superfície `security definer` nova. A precedência é banco→env (precedente `zernio/credentials.ts`), então o `.env` continua sendo piso de rollback. Aditiva: tabela nova, sem dado a curar. |
| `20260827060000` | `0192_a_poda_de_nonces_esqueceu_authenticated` | `fn_expurgar_nonces_de_oauth` revogava só de `public, anon` — as duas irmãs de assinatura idêntica (`fn_expurgar_auditoria_vencida`, `fn_expurgar_espelho_da_agenda`) revogam também de `authenticated`. O grant vem do `ALTER DEFAULT PRIVILEGES ... TO authenticated` do baseline, então a falha é uma linha AUSENTE, não errada: qualquer usuário logado de qualquer org podia apagar, pelo PostgREST, os nonces de OAuth de TODOS os tenants (a definer não tem recorte por org — não precisa: o único chamador é o cron de retenção com service role). Não vaza dado, só apaga — e é por isso que passou na revisão. Achado pela varredura de `tests/invariants/hardening-definer-varredura.test.ts`, não por leitura. Forward-fix da 0190. |
| `20260828000000` | `0202_o_nome_do_atendente_custava_uma_chamada_http` | **Toda listagem do Inbox pagava 1 requisição HTTP ao GoTrue Admin API por atendente único da página.** `comNomeDoAtendente()` → `nomesDosAtendentes()` (`lib/users/nome-do-atendente.ts`) resolve o nome de quem atende chamando `admin.auth.admin.getUserById` — o próprio arquivo já media o custo (~60ms para 1, ~350ms para 10, ~1,2s para 50 atendentes únicos) e já apontava o conserto: desnormalizar, como o repo já faz em `conversation_notes.created_by_name`. **Entra em `fn_conversation_assign`, e não em 4 arquivos TS:** toda atribuição de conversa — claim, release, transfer, roteamento automático — passa por esta ÚNICA função SECURITY DEFINER; não existe UPDATE direto de `assigned_to_user_id` em nenhum outro lugar do repo. `conversations.assigned_to_user_name` (nullable) é gravada no MESMO update que grava o id, lida de `auth.users.raw_user_meta_data->>'full_name'` (mesmo path de `admin/users/route.ts` e `admin/platform-admins/route.ts`), e zerada junto quando a atribuição sai (release). Assinatura IDÊNTICA de propósito — parâmetro novo criaria overload e as chamadas por nome existentes passariam a falhar com `is not unique`. O app-side (`comNomeDoAtendente`) passa a ler a coluna já presente na linha, com `nomesDosAtendentes()` mantida só como fallback do caso raro em que o id está preenchido e o nome não (linha de antes desta migration, backfill que não alcançou). Aditiva e idempotente: coluna nullable com `if not exists`, backfill guardado por `where assigned_to_user_name is null`, `create or replace function`. |
| `20260831000000` | `0203_comando_da_conversa` | O banco passa a saber QUEM MANDA na conversa. `fn_comando_da_conversa` (a regra, `immutable`, com `p_agora` parametrizado para o teste de espelho ter relógio fixo) e `comando_da_conversa(conversations)` (campo calculado que o PostgREST publica em `?select=` e em `?comando_da_conversa=in.(...)`). PORQUÊ: as abas da Inbox descreviam quem manda lendo `conversations.status`, coluna que o motor de IA nunca lê — medido na VPS, a aba IA mostrava 2 e a Fila 83 enquanto o motor atendia 49. O filtro precisa de `contacts.force_human`/`is_blocked` (outra tabela), então reescrevê-lo no query builder seria a regra em duas encarnações. Casadas por `tests/invariants/comando-da-conversa-espelha-o-ts.test.ts`. Invoker de propósito (respeita RLS de quem pergunta), e por isso a varredura anon do fim do baseline não as alcança — revoke/grant explícitos. |
| `20260901120000` | `0204_catalogo_de_produtos_da_loja` | **O catálogo que a LOJA possui — a tabela que faltava para o agente de IA responder preço EXATO.** Até aqui a única tabela de produto do schema era `nuvemshop_products`, que é ESPELHO de loja remota: medido, ela não tem UM escritor no repositório inteiro e o indexador do RAG nem a lê (vai à API da Nuvemshop direto). Uma loja física sem Nuvemshop não tinha onde guardar preço como DADO — sobrava o RAG, e preço em busca semântica é armadilha conhecida: o trecho de 'iPhone 15 Pro 256GB' casa com o do 'iPhone 15 128GB' e o agente responde o valor errado. **Tabela NOVA e não `nuvemshop_products` + coluna `origem`, por quatro razões em ordem de força:** (1) a policy daquela tabela é `for all` org-flat sem `fn_role_at_least` — qualquer `viewer` reescreve preço pelo PostgREST com o JWT dele —, e ela está congelada na allowlist de dívida de RBAC, que é fechada para tabela nova; (2) `external_id`, `last_updated_at` e `payload` são `not null` de espelho, e produto digitado à mão teria de inventar os três, com `unique(org, external_id)` impondo chave falsa; (3) dois donos de escrita na mesma tabela — no dia em que o sync ganhar o escritor que lhe falta, um `where origem = 'nuvemshop'` esquecido apaga o catálogo digitado à mão; (4) o nome mente em instalação sem Nuvemshop. **Uma linha por item VENDÁVEL**, não pai+variações: quem tem preço é o SKU, e um pai sem preço não responde ao cliente (DIRC letra C — o 'modelo' é derivável de marca+categoria+nome). `controla_estoque` conserta uma armadilha da tool antiga, que filtra `available_qty > 0` por default e deixaria invisível o catálogo de quem não conta estoque (decant, sob encomenda). RLS no molde da 0177: leitura para a org, escrita só de `manager` para cima. Índice GIN trigram em `nome` para a parte difusa da busca por token (ver `lib/catalogo/busca.ts`). |
| `20260902020000` | `0205_um_agente_indexa_mais_de_um_material` | **Um agente só conseguia ter UM material indexado — para sempre, e em silêncio.** A migration 0181 mudou a semântica do número da versão de acervo: passou a ser "a quantas indexações DESTE material", contado por `knowledge_source_id` — é o que `lib/ai/rag/version.ts` faz e o que o comentário dele diz. O índice único não acompanhou: seguiu em `(agent_id, version_number)`. Como toda fonte nova nasce com `version_number = 1`, a SEGUNDA fonte do mesmo agente colidia com a primeira. **Não é corrida, é determinístico.** Medido em produção: cinco materiais subidos para o mesmo agente, um indexou com 14 trechos e quatro pararam em `pending` com `attempts=2`; a tela mostrava os cinco como `ready`, com `chunks_count = 0`. **Dois índices parciais e não um**: as versões anteriores à 0181 têm `knowledge_source_id` NULL e continuam válidas, e um índice puro por fonte as deixaria SEM restrição nenhuma (em Postgres NULL não colide com NULL) — o que afrouxaria um invariante que hoje vale. Cada regime guarda o seu. A dedup vem ANTES do índice porque um clone com duplicata do regime novo quebraria o `update.sh` no meio. |
| `20260903143000` | `0210_tarefas_do_crm` | **O produto não tinha onde guardar "ligar de volta na terça".** Lembrete de trabalho interno com prazo vivia na cabeça de quem atende — ou em `custom_fields` do lead, jsonb sem schema, sem índice por prazo e invisível para qualquer lista que pergunte "o que vence hoje". **Tabela nova e não a Agenda (0177):** `calendar_appointments` marca COMPROMISSO COM O CLIENTE, com horário, local, convidado e confirmação; uma tarefa não tem nada disso e pode nem ter dono — pôr as duas na mesma tabela obrigaria a inventar convidado e local para cada lembrete. `due_date` é `timestamptz` e **nullable**: "algum dia eu preciso" é tarefa legítima, e forçar data faria o operador inventar uma, envenenando a lista de atrasadas — que é a única razão de a coluna existir. `lead_id`/`contact_id` são `on delete set null`, não cascade: apagar um funil não pode apagar o que a pessoa escreveu para si mesma. `assigned_to`/`created_by` são FK para `auth.users`, não texto (anti-pattern nº 1). **Duas policies e não uma `tenant_isolation_*_all`:** uma policy `for all` org-flat deixaria qualquer `viewer` apagar tarefa dos outros pelo PostgREST com o JWT dele — a mesma crítica que a 0204 faz a `nuvemshop_products_tenant`. Leitura para a organização; escrita a partir de `agent`, o papel de quem atende. Extraída do PR #418 (@clinicacentrodosorrisosc-code): lá a tabela existia só no `baseline.sql`, sem arquivo de migration — o que a deixaria fora de qualquer clone que aplique `migrations/`. |
| `20260904050000` | `0212_o_email_do_convidado` | **O agendamento marcava a hora e não convidava ninguém.** `calendar_appointments.guest_email` (text, nullable) captura o e-mail de um convidado externo no formulário de novo agendamento; quando presente, ele vira `attendees` no evento do Google Calendar e o convite sai por e-mail (`sendUpdates=all` na chamada de escrita). Nulo mantém o comportamento anterior — evento sem convidado — então a coluna é aditiva e nenhum agendamento existente muda de significado. Idempotente (`add column if not exists`). |
| `20260904051000` | `0213_conversoes_de_anuncio` | **A venda fechava no CRM e o anúncio nunca ficava sabendo.** Desde a 0164 o `ctwa_clid` era estampado em `contacts.source_metadata` pelo primeiro toque — e o dado era SÓ DE ESCRITA: nada o lia de volta, então quem paga tráfego sabia de onde o lead veio e não conseguia dizer à plataforma quais leads viraram dinheiro. Duas tabelas. `ad_platform_connections` (org-escopada, única por `(organization_id, platform)`) guarda dataset e token cifrado por `fn_encrypt_oauth` — as MESMAS RPCs de `calendar_connections` e `lib/webhooks/secrets.ts`, sem terceiro caminho de cifra. Escopo de ORGANIZAÇÃO e não singleton de instalação como `platform_google_oauth` (0201): o dataset pertence à conta de anúncios do negócio, e uma agência com dois clientes na mesma VPS reportaria a venda de um na conta do outro. E o eixo é INDEPENDENTE do canal de mensagem — guardar em `channel_sessions` amarraria "reportar venda" a "ter canal oficial", quebrando quem recebe clique-para-WhatsApp em outro transporte. `ad_conversion_dispatches` faz três trabalhos numa tabela só, de propósito: idempotência (índice único `(organization_id, lead_id, event_name)` — contar a mesma venda duas vezes envenena o otimizador e não tem sintoma), superfície de falha para a tela `/app/settings/conversoes` (invariante 6 da doutrina de restrição de canal, lição da #144), e prova do que foi enviado. AS DUAS com RLS ligada e ZERO policies, grants revogados de anon/authenticated — mesmo desenho de `platform_google_oauth`, porque o token escreve na conta de anúncios do cliente e vazá-lo deixa terceiro injetar conversão falsa. Nenhuma função nova em `public` ⇒ item 9 da doutrina não é acionado. |
| `20260904052000` | `0214_leitura_de_anuncios` | **Quem paga a mídia não via a mídia.** A 0213 fechou o caminho de VOLTA (a venda vira conversão na plataforma); o de IDA continuou fora do produto — para saber quanto custou o lead que acabou de atender, o dono do tráfego abria o Gerenciador numa aba separada. `ad_insights_connections` (org-escopada, única por `(organization_id, platform)`) guarda o token de LEITURA cifrado por `fn_encrypt_oauth`, a mesma cifra de `calendar_connections`, `channel_sessions` e da 0213 — sem terceiro caminho de cifra. **Tabela NOVA e não coluna em `ad_platform_connections`**, por quatro razões das quais a primeira já decide: (1) o índice único da 0213 é `(organization_id, platform)` e os dois tokens têm ESCOPOS DIFERENTES na plataforma (escrita de conversões vs. `ads_read`); (2) `enabled` é um interruptor só, e dividir a linha faria pausar o envio de conversões apagar o dashboard e vice-versa; (3) `dataset_id`/`test_event_code` são vocabulário de conversão e ficariam nulos para sempre; (4) raio de explosão — `lerCredencial()` alimenta o worker de `lead.won`, e um upsert desta feature na linha compartilhada derrubaria o reporte de vendas de quem nunca abriu a tela nova. **Sem `enabled` de propósito**: nada roda sozinho aqui (a leitura só acontece quando alguém clica em Atualizar), então um estado "conectado mas desligado" teria a mesma consequência visível de não estar conectado — desconectar é apagar a linha. RLS ligada com ZERO policies e grants revogados de anon/authenticated, mesmo desenho da 0213 e de `platform_google_oauth`: um token `ads_read` lê menos que o de conversões, e ainda assim expõe orçamento, criativo e performance de quem anuncia. Nenhuma função nova em `public` ⇒ item 9 da doutrina não é acionado. |
| `20260903230000` | `0211_contact_custom_fields` | **O contato ganha campos personalizados — e a anonimização passa a alcançá-los.** A definição continua declarativa em `crm_pipelines.settings.fields[]`, a mesma fonte que `crm_leads.custom_fields` já lê; o que entra aqui é só o VALOR, em `contacts.custom_fields jsonb not null default '{}'` com CHECK de `jsonb_typeof = 'object'` (dados corrigidos ANTES da constraint, porque o `update.sh` roda sem `ON_ERROR_STOP` e engoliria o 23514, deixando a coluna sem validação em silêncio). **A segunda metade é a que não podia faltar:** campo livre num registro de pessoa física recebe CPF — é o primeiro uso que um operador de clínica dá a um campo chamado "documento" —, e uma coluna de PII fora da anonimização faz o sistema responder "anonimizado" a um pedido do titular com o dado dele intacto, com o SLA de D+15 marcado como cumprido. **A limpeza é TRIGGER NO ESTADO, não linha dentro do cascade**, pela mesma razão já registrada em `trg_contacts_anonimizado_limpa_propostas`: há mais de um caminho que anonimiza — `fn_lgpd_cascade_redact_contact` e `app/api/v1/lgpd/anonymize/route.ts:104`, que faz UPDATE próprio e nem limpa `consent`/`tags`/`source_metadata` —, então pendurar num deles deixaria o outro vazando. `BEFORE UPDATE OF is_anonymized` porque o alvo é coluna da própria linha: em `after` seria preciso um segundo UPDATE, com a recursão que ele traz. Os dois revokes do item 9 do CLAUDE.md, e o bloco entra no baseline ANTES da varredura de `anon` (ele cria função). Aditiva e idempotente. Achado de @prevprocesso-maker no PR #465; a metade da LGPD foi acrescentada aqui. |
| `20260903000000` | `0206_contact_ai_authorization` | **A IA respondia `allow by default`: publicou agente para a sessão de WhatsApp, ela atendia TODO mundo.** (Renumerada duas vezes na fila da `main`: 0202 → 0203 → **0206**, porque `0203_comando_da_conversa`, `0204_catalogo_de_produtos_da_loja` e `0205_um_agente_indexa_mais_de_um_material` entraram antes.) Não há gate de elegibilidade — `lib/channels/pos-entrada.ts` emite `ai_agent.dispatch_requested` para todo inbound novo, `lib/agent-engine/edge/crm/drain.ts` só checa se existe agente publicado para a sessão, e `resolve-turn-agent.ts` sempre resolve alguém ("silêncio não é desfecho possível", regra 5). Num número que é também o WhatsApp pessoal/comercial do dono, a IA assumia conversa de cliente atual, fornecedor, contato pessoal e conversa antiga. Correção: gate OPT-IN por canal (`channel_sessions.metadata.ai_gate = 'allowlist'`; ausente/`'open'` = comportamento de hoje, zero self-hoster afetado). Com o gate, a IA só responde quando o CONTATO está autorizado — `ai_authorized_at` (NULL = não autorizado) + `ai_authorized_reason` (`respondi:<form>:<submission>` \| `campanha:<id>` \| `automacao:<rule>` \| `retomada_manual`). Contact-level como `force_human`, a trava oposta. Janela de validade (`AI_ALLOWLIST_TTL_DAYS`, default 21) impede submissão antiga reativar a IA; o turno autorizado renova o carimbo enquanto a conversa está viva. Aditiva e idempotente: colunas anuláveis, sem default, sem constraint — nenhuma linha existente viola nada, `update.sh` de clone não quebra. Anti-backlog no mesmo PR (sem schema): o drain pula evento cuja mensagem-gatilho já foi superada por inbound mais recente — vale para toda instalação, conserta "reiniciar o worker dispara histórico". |
| `20260903210000` | `0207_credenciais_de_ia_voltam_a_ser_lidas_por_manager` | **Membro que não é admin via a lista de credenciais de IA VAZIA — e concluía que não havia nenhuma cadastrada.** A 0150 removeu a policy de SELECT por tenancy e deixou `tenant_isolation_ai_provider_credentials_write` como ÚNICA policy da tabela. Em Postgres, `FOR ALL` cobre também a leitura: a única regra aplicável ao SELECT passou a exigir `fn_role_at_least(organization_id, 'admin')`. A view `ai_provider_credentials_safe` é `security_invoker = true` de propósito — para que a RLS da base valha para quem consulta —, então um `manager` ou `viewer` passa na autorização da aplicação e é filtrado para ZERO LINHAS na base. A tela `/app/ai/credentials` não é admin-gated (ela só calcula `canWrite` para admin) e responde 200 com `[]`: sem erro, sem aviso, e a pessoa conclui que a organização não tem credencial. **Restaura o PAR que o cabeçalho da própria 0150 promete** — escrita de admin, leitura por tenancy. **Reabrir o SELECT não reabre o segredo**: quem esconde `api_key_encrypted/iv/tag` é o GRANT POR COLUNA da 0150, não a RLS; há controle positivo em `tests/invariants/` medindo que o `manager` lê a view segura e continua SEM alcançar as três colunas do segredo. O `drop policy if exists` antes do `create` não é zelo: o apêndice é re-aplicado pelo `update.sh` **sem** `ON_ERROR_STOP`, e o dump cria policy de mesmo nome — sem o drop, o create falharia em silêncio num clone. |
| `20260905160000` | `0218_configuracao_pre_go_live_atomica` | **O canal ganha uma transição segura entre pré-go-live e atendimento aberto sem regravar o jsonb inteiro.** `fn_configurar_pre_go_live_canal` altera, numa única instrução e com filtro explícito de organização/canal ativo, somente `ai_gate`, `ai_gate_mode` e `ai_test_phone_numbers`; assim uma gravação concorrente de metadata do transporte não é apagada. O pré-go-live continua sendo o gate `allowlist`, mas o marcador separa a lista de testadores das autorizações temporárias de campanha/automação. A lista aceita apenas E.164; a RPC é invoker, exclusiva de `service_role`, com revogação das duas origens de EXECUTE. Não há backfill: canais existentes preservam o comportamento atual, e somente canais novos criados pelo produto nascem fechados. |
| `20260904220000` | `0217_relatorio_de_atividades` | **`crm_lead_activities` só era legível por negócio e por contato — "o que a equipe fez esta semana" obrigava a abrir negócio por negócio.** Acrescenta `fn_activity_report(p_org, p_from, p_to, p_tz, p_limit)`: uma agregação da janela em total, por ator (a tripla `actor_kind`/`performed_by_user_id`/`actor_agent_id`), por tipo, série diária e as N linhas mais recentes com o negócio e o contato. **A contagem roda no Postgres de propósito**: trazer a janela inteira pela rede para contar em JavaScript é o que derruba uma VPS de 2 GB — é como o PR #418 fazia no deploy de origem, onde a base é de uma clínica só. **SECURITY INVOKER como a 0037/0133**: o escopo é a policy `crm_lead_activities_select` (0042), que já herda a visibilidade do negócio-pai por `fn_can_view_lead` — `agent` em modo 'own' colapsa às próprias atividades, viewer/manager/admin veem a org. Promover a DEFINER vazaria entre inquilinos, e `tests/invariants/relatorio-de-atividades.test.ts` reprova quem tentar. **O fuso é parâmetro**: agrupar o dia em UTC joga a atividade das 21h de Brasília no dia seguinte, e o relatório afirmaria trabalho num dia sem trabalho. Índice novo `idx_lead_activities_org_perf` porque os três existentes lideram por org mas seguem com `contact_id`/`lead_id`/`type` — nenhum serve a um recorte de período org-wide. Extraído da contribuição de **@clinicacentrodosorrisosc-code** (PR #418). |
| `20260905120000` | `0231_organizacao_e_acesso_atomicos` | Organização + admin atômicos e idempotentes; aceite serializado não restaura privilégio em replay. |
| `20260905150000` | `0219_recibo_de_criacao_confiavel` | Recibo de criação como autoridade exclusiva do servidor; marker de procedência, RLS restritiva e bloqueio de TRUNCATE sem afetar LGPD/MCP. |
| `20260904011000` | `0209_mover_em_lote_sem_colidir_posicao` | **Mover N cards de uma vez pelo funil gravava a MESMA `position_in_stage` nos N.** `BulkActionBar` mandava um escalar fixo (`1_000_000`) para o lote inteiro e o handler o aplicava com um `update ... in (ids)`. `position_in_stage` é fractional indexing, e `midpoint(prev, next)` de `lib/kanban/fractional-indexing.ts` devolve **NaN** quando os dois vizinhos empatam — o próprio arquivo diz que o chamador deveria disparar um rebalance global, que não existe. Consequência em duas camadas: a ordem entre os cards do lote fica indefinida (o quadro se reordena a cada refetch) e o primeiro arrasto para ENTRE dois deles manda NaN como posição. `fn_mover_leads_em_lote` dá a cada card do lote uma posição DISTINTA — piso da etapa de destino + 1000 por card (o mesmo STEP do TypeScript), na ordem em que estavam no quadro — num `update` único. **Função e não N updates no handler**: pelo PostgREST, um valor por linha seria ou `upsert` (obrigando a devolver todas as colunas NOT NULL) ou até 50 idas ao banco, e um lote que falhasse na 17ª deixaria 16 movidos e 14 parados sem quem desfizesse; o `update` único torna o lote atômico. **`security INVOKER` de propósito** (a RLS de `crm_leads` é o piso, e nada aqui precisa vê-la de fora), e por isso a varredura de `anon` do fim do baseline — que só alcança `prosecdef` — não a cobre: revoke/grant explícitos das duas origens. Devolve a etapa de ORIGEM de cada card para o handler seguir emitindo uma atividade de timeline por lead movido. |
| `20260904000000` | `0208_moeda_da_organizacao` | **O produto inteiro presumia real, e a presunção não morava em lugar nenhum que alguém pudesse mudar.** `catalog_products.moeda` (0204) nasce `'BRL'` e — medido no fonte — ninguém manda outra coisa: o formulário de "Novo produto" não declara o campo no `Rascunho` nem o envia no `doRascunho()`, e `lib/catalogo/planilha.ts` não mapeia coluna de moeda. Só a ROTA aceita `moeda` no corpo, o que é pior que não aceitar — nenhuma tela oferece, então o único jeito de um produto não ser BRL é chamar a API por fora do produto. Uma loja no México cadastra em pesos, o banco guarda `'BRL'`, e o agente cota esse número ao cliente com o símbolo errado: o dado está certo e o rótulo mente. **Coluna na `organizations`, e não `settings` jsonb:** é a mesma classe de `locale` e `timezone`, que já são colunas de primeira classe da tabela; jsonb aqui seria o anti-pattern 6. **Nome `currency` e não `moeda`:** a doutrina manda `_cents` + `currency` ISO-4217 e duas das três tabelas com dinheiro já obedecem (`crm_leads`, `orders`) — `catalog_products.moeda` é o desvio, e renomear coluna já distribuída quebraria o `update.sh` de quem instalou, então ela fica e a nova não propaga o desvio. DIRC: não é duplicação sem dono (a org é a fonte na ESCRITA, e a linha guarda a moeda com que nasceu — o certo para `orders`, que são fatos históricos), nenhuma FK a traz, e não é calculável: é declaração de quem opera. O CHECK é de FORMA (`^[A-Z]{3}$`), não de vocabulário fechado, então fica FORA de `vocabulario-banco-x-typescript` — precedente idêntico e deliberado no irmão `catalog_products_moeda_iso`. Aditiva: o default satisfaz a constraint em toda linha existente; o `update` que vem antes dela existe para o clone onde a coluna tenha sido criada à mão. |
| `20260904190000` | `0215_juntar_contatos_duplicados` | **`fn_mesclar_contatos` — a mesma pessoa cadastrada duas vezes vira uma, sem perder histórico.** `contacts.is_merged_into` existe desde a 0003 e é o que faz os três índices únicos parciais (telefone, e-mail, CPF) tolerarem o perdedor — mas o único produtor dela era a data migration de mão única da 0027. `merge_queue` está no schema desde a 0003 **sem produtor**, `contact.merged` está em `lib/audit/actions.ts` **sem emissor**, e `components/contacts/MergeDialog.tsx` dizia ao operador, na tela, "mesclar via SQL". Esta é a peça que faltava para os três. **A lista de FKs a repontar sai do CATÁLOGO (`pg_constraint`), não de uma lista à mão**: a doutrina de migrations manda conferir o catálogo, e uma lista escrita à mão envelhece em silêncio — a tabela criada amanhã apontando para `contacts` não entraria nela e o histórico ficaria pendurado no perdedor. O ponteiro POLIMÓRFICO (`crm_lead_links.target_id` com `target_kind='contact'`) entra explicitamente porque catálogo nenhum o enxerga. **O perdedor vira LÁPIDE, não `delete`**: nenhuma FK fica órfã mesmo quando uma linha não pode ser repontada (colisão com `uniq_job_queue_one_running_per_contact`, estado efêmero de runtime — repontada linha a linha, com o que sobrou contado no retorno), e são os índices parciais que liberam telefone/e-mail para o vencedor herdar. Herda `waha_lid` porque `wa_identity`/`wa_lid` são GERADAS: sem isso `fn_upsert_wa_contact` (que filtra `is_merged_into is null`) criaria um contato novo na mensagem seguinte e **refaria a duplicata que acabou de ser desfeita**. CPF e `consent` NÃO são herdados — CPF é par preso por check e cifrado com a chave da instalação; `consent` é registro legal de quem autorizou, e herdá-lo fabricaria consentimento. Recusa contato anonimizado (L-04 não se desfaz pela porta dos fundos) e de outra org. Extraído do PR #418 de @clinicacentrodosorrisosc-code, que resolveu o mesmo problema no nível do CARD do funil; aqui a peça é o CONTATO, que é a mesma em todo nicho. |
| `20260904210000` | `0216_agent_inbox_items_resolved_at` | **O aviso "número calado" nunca se fechava sozinho, mesmo com o agente respondendo normalmente.** `lib/agent-engine/pacing/aviso-de-janela.ts` (commit 4912c5230, 2026-08-30) resolve o aviso de janela de envio fechada com `update agent_inbox_items set status = 'resolved', resolved_at = now()`, mas `agent_inbox_items` nunca teve coluna `resolved_at` — nem no snapshot original, nem em migration nenhuma depois. O erro (`column "resolved_at" ... does not exist`) é engolido de propósito (fire-and-forget, para não derrubar a resposta ao lead), então o UPDATE falhava em SILÊNCIO a cada turno dentro da janela, e o aviso "as respostas da IA estão esperando a janela de envio abrir" ficava aberto na Central para sempre — dando a impressão de canal/loja fechada mesmo com tudo funcionando. Medido em produção: dezenas de falhas por hora, todo dia, desde que o código de resolução foi publicado. Correção aditiva e nullable: `add column if not exists resolved_at timestamptz`. |

## Reproducibility

Migrations were applied directly via the Supabase MCP `apply_migration` tool during the autonomous bootstrap session. The SQL of each migration is also embedded in the corresponding spec under `docs/specs/0X-spec-*.md` and the database keeps them in `supabase_migrations.schema_migrations`.

To re-apply on a fresh Supabase project, replay the migrations in version order via `supabase db push` (Supabase CLI) or via the MCP.

## Tables created (33 total, all RLS enabled)

- **Platform**: organizations, user_organizations, platform_admins, api_tokens, api_audit_log, user_recovery_codes, idempotency_keys
- **Bus**: event_log
- **Customer 360**: contacts, crm_pipelines, crm_stages, crm_leads, crm_lead_activities, crm_lead_links, merge_queue
- **WhatsApp**: channel_sessions, channel_session_warmup, conversations, messages, webhook_events_log
- **AI**: ai_agents, ai_knowledge_sources, ai_knowledge_versions, ai_chunks, ai_invocations, ai_pricing (global), ai_budgets, ai_faq_items
- **Integrations**: tenant_integrations, orders, nuvemshop_products
- **Compliance**: lgpd_requests
- **Ops**: incidents

| `20260905210000` | `0220_suporte_temporario_por_sessao` | Sessão temporária vinculada ao Auth, papel efetivo e cercas readonly de DML/RPC/Storage; baseline idempotente. |

| `20260906010000` | `0221_interface_por_vinculo` | Interface por membership, aceite assinado/replay preservado, criação responsável e publicação Realtime. |

| `20260906020000` | `0222_fronteira_do_atendimento` | Atendimento com revisão, entrada serializada, encerramento explícito da demanda e provenance operacional. |

| `20260906030000` | `0223_origem_imutavel_do_evento` | Recibo server-only da origem por evento, compartilhado entre consumers/retries; emissor autenticado recusa campos reservados de origem. |

| `20260906120000` | `0224_presenca_e_recuperacao` | Presença humana com CAS e autoria, relógio de confirmação, recibo privado da recuperação, invalidação por inbound e proteção compartilhada. PG15; baseline INSTALL/UPDATE. |

| `20260907010000` | `0225_google_reconciliacao` | Destino pessoal, reconciliação Google com revisão/claim/CAS, cursor cercado, redação de snapshots e elegibilidade derivada do titular e dono obrigatório na decisão. |

| `20260907020000` | `0226_meet_e_entrega_transacional` | Conferência assíncrona na identidade/claim Google; entrega transacional com fronteira e aquisição original, retry humano, estado visível e minimização LGPD. |

| `20260907030000` | `0227_autonomia_e_respostas_revisadas` | Pausa preserva publicação; modo assistido, revisões específicas de contexto, rascunho/feedback e envio aprovado cercado pela aquisição original; exportação e redação. |
| `20260907040000` | `0228_roteamento_por_canal_e_reservas` | Responsáveis por canal, claim automático com capacidade global e retry durável; reserva WAHA com recibo privado, lease e retry da mesma identidade. Independente de 0227; baseline PG15. |
| `20260907050000` | `0229_mfa_e_lgpd_agenda` | MFA nas quatro ações humanas, ordem de locks LGPD/agenda e footprint de avisos de presença/Meet na redação; baseline e backfill idempotentes. |

| `20260907060000` | `0230_reserva_pre_go_live` | A reserva transacional de novos canais WAHA preserva o pré-go-live da plataforma; retry mantém política e identidade existentes. Forward-fix da integração, sem alterar 0228 aplicada. |
| `20260920001000` | `0347_modulo_voip` | **Módulo VoIP (SIP/Asterisk + IA de voz via OpenAI Realtime)** — mantém só `ai_agents.channel` (coluna aditiva, `default whatsapp`, separa o eixo canal do eixo `kind` já existente) e `phone_numbers`+`fn_resolve_inbound_number` (roteamento de DID→org, com os dois `revoke`/`grant` do item 9 do `CLAUDE.md`). **O `crm_calls` original desta migration foi removido**: a `main` mergeou nesse meio-tempo uma tabela de chamadas própria (`voice_calls`, WhatsApp/WaCalls, #628/#697) — ver `0337` abaixo, que unifica os dois em vez de manter duas tabelas de chamada que não conversam entre si. **Arquivo RECOMPOSTO na renumeração (18/09):** nasceu `0232_modulo_voip` no PR #677 e foi apagado por acidente no commit `97eed0955`; o bloco do apêndice do baseline tinha sobrevivido, e é dele que o arquivo foi refeito. |
| `20260920001100` | `0348_voice_calls_sip` | **Unifica `crm_calls` (módulo SIP; criada pela antiga `0232` deste PR, que a `0336` recomposta já não cria) dentro de `voice_calls` (WhatsApp/WaCalls, `0233`-`0236`)** — a triagem do PR #677 já tinha apontado que duas tabelas de chamada que não conversam é o primeiro ponto a resolver na integração; isto adianta essa resolução. `channel_session_id`/`wacalls_call_id` viram nullable (SIP não tem sessão pareada nem id do binário WaCalls); `provider` discrimina `wacalls`/`sip`; colunas aditivas `asterisk_channel_id`/`lead_id`/`ai_agent_id`/`handled_by`/`transcript`/`metadata`, todas nullable/default seguro — nenhuma linha de WhatsApp existente é afetada. Vocabulário de `status` (`starting`/`ringing`/`connected`/`ended`) é do binário WaCalls upstream, preservado sem alteração — o fluxo SIP é mapeado nele (`ringing`→`ringing`, `in_progress`→`connected`, terminal→`ended`+`end_reason` livre) em vez de estender o `CHECK`. Migra as linhas existentes de `crm_calls` e derruba a tabela antiga no mesmo arquivo (nunca mergeada em `main`, sem histórico externo a preservar). `fn_lgpd_cascade_redact_contact` ganha `transcript = null` na redação de `voice_calls` — sem isso, anonimizar um contato deixaria a transcrição da ligação (que pode conter o nome dele, falado em voz) intacta. Aditiva e idempotente. **Na renumeração (18/09), a `fn_lgpd_cascade_redact_contact` foi refeita sobre a definição VIGENTE da main** (só acrescenta `transcript = null`): redefinir a partir da cópia de quando o PR nasceu desfaria em silêncio o que a main consertou na cascata depois. |
| `20260920001200` | `0349_voip_trunk_settings` | Tela de configuração de trunk SIP por organização (Configurações > Trunk SIP) — hoje o trunk é fixo em `asterisk/pjsip.conf`, fora do banco. `voip_trunk_settings` (chave primária `organization_id`, um trunk por org) guarda host/porta/usuário e senha cifrada AES-256-GCM (mesmo esquema de `ai_provider_credentials`, `lib/crypto/aes_gcm.ts`) + `endpoint_name` derivado (`org-<uuid>-trunk-endpoint`, nunca digitado, pra nunca divergir do bloco em `pjsip.conf`). View `voip_trunk_settings_safe` (`security_invoker=true`) nunca expõe a senha cifrada. RLS: leitura por qualquer membro da org, escrita só `admin`. Aplicar no Asterisk continua MANUAL nesta fase (decisão explícita) — a tela mostra o bloco pronto pra colar logo após salvar (única janela em que a senha em claro existe, no form do navegador — nunca reconstituível depois). `app/api/v1/calls/route.ts` passa a resolver o `endpoint_name` daqui, com fallback pra `VOIP_TRUNK_ENDPOINT` (env) enquanto uma org não migrou pra tela. |

| `20260914230000` | `0253_politica_de_cadastro` | `platform_settings` (singleton id=1) guarda se a instalação aceita cadastro aberto ou só por convite. Default `aberto` — preserva o comportamento anterior. RLS ligada com zero policies; só `service_role` lê e escreve. Baseline PG15, idempotente. |
| `20260907120000` | `0233_chamada_de_voz_wacalls` | **Schema pro canal de chamada de voz (WaCalls)** — spec `docs/specs/18-spec-voice-calls-wacalls.md`. `channel_sessions` ganha o provider `'wacalls'` (mesmo padrão de `'zernio'`/`'meta_cloud'`: colunas nullable específicas de provider na mesma tabela — `wacalls_session_id`, `wacalls_jid`, `wacalls_paired_at` — em vez de tabela por provider), com a mesma dupla de CHECK que os outros três já têm (vocabulário fechado + coluna de identidade obrigatória por provider). Tabela nova `voice_calls` (RLS via `fn_user_org_ids()` desde já, `updated_at` por `fn_set_updated_at` — o padrão de 27 tabelas contra as 5 de `fn_touch_updated_at`). Não guarda `recording_url`: gravação de chamada fica deliberadamente fora desta versão (decisão de produto §1.2 da spec — grava voz é dado sensível LGPD, sem consentimento nem entrada no cascade de redact ainda). Provado 3× contra o projeto Supabase Cloud de teste: install limpo + 2 reaplicações idempotentes, todas exit 0. |

| `20260907130000` | `0234_voice_calls_no_realtime` | **Forward-fix da 0233: `voice_calls` nunca entrou na publicação `supabase_realtime`.** Achado testando ao vivo, não em revisão de código — uma ligação de verdade chegou no número pareado, o worker gravou `status='ringing'` certinho em `voice_calls` (log confere), e o frontend (`hooks/voice/useVoiceCallSession.ts`, que assina a tabela via `useRealtimeChannel`) ficou mudo: `IncomingCallBanner` nunca apareceu, a chamada tocou 46s e encerrou sem resposta com a tela quieta o tempo todo. `select from pg_publication_tables` confirmou a tabela ausente da publicação antes do fix e presente depois. Mesmo padrão idempotente das migrations 0025/0071/0078/0082/0183. |

| `20260908140000` | `0235_voz_isolamento_lgpd_e_dono` | **Forward-fix da 0233 em seis blocos, todos idempotentes.** (1) A policy `tenant_isolation_voice_calls_all` nasceu sem o `for all` explícito e sem `revoke all ... from anon`: o comportamento casava com o nome por default do Postgres, não por declaração — recriada na forma que 0067/0050/0068/0085 fixaram. (2) `voice_calls` guarda `peer_phone`, telefone de gente, e estava FORA de `fn_lgpd_cascade_redact_contact`: depois de anonimizar um contato o número real sobrevivia ligado ao `contact_id`, um caminho de reidentificação — mesmo argumento que a foto de perfil já tinha em `lib/lgpd/redact-cascade.ts`. Entra como passo 7b, preservando direção, status, motivo e tempos (o registro de "houve uma chamada de 12 minutos" não identifica ninguém e sustenta métrica e fatura). (3) Coluna nova `owner_user_id` — quem esteve NA LINHA —, com índice parcial `(organization_id, owner_user_id, answered_at) where answered_at is not null`: sem ela qualquer colega da organização desligava a ligação de qualquer outro e a linha do tempo dizia "Sistema". (4) `fn_update_last_activity_at` ganha `'voice_call'` na lista positiva da 0079, e **só** ele: `voice_call_missed` fica de fora de propósito, porque telefone que tocou sem resposta é constatação de silêncio, não quebra dele. (5) `fn_attendant_metrics` ganha a CTE `voice_agg` e os campos `calls_answered`/`call_seconds`: quem passa o dia ao telefone tinha produtividade zero. (6) A FK `voice_calls.channel_session_id` vira `on delete restrict` — apagar o canal deixa de apagar o histórico de ligações em cascata silenciosa e passa a exigir arquivamento, a mesma garantia que `conversations`/`messages` já davam. |
| `20260909190000` | `0232_nome_de_sessao_waha_cabe_no_teto_do_waha` | `fn_reserve_channel_connection` gerava `waha_session_name` de 69 chars (`org_<32>_<32>`); o WAHA latest-2026.7.2 valida `name` com @MaxLength(54) e todo `POST /api/sessions` de canal novo tomava 400 (`waha_create_400`). Prefixo da org encurta para 8 (`org_<8>_<32>` = 45), alinhado com a busca de canal de onboarding no mesmo corpo. Repara canais WAHA nunca pareados com nome fora do teto. Forward-fix da 0230. |
| `20260911170000` | `0238_convites_de_time_persistidos` | `team_invites`: o convite pendente passa a existir no banco (antes era só token stateless + linha no aceite). Habilita a lista de convites na tela de Equipe, o aviso de e-mail não despachado e a REVOGAÇÃO de convite (o id da linha = invite_id do token; o aceite recusa convite revogado). Status é derivado. RLS `team_invites_select` (manager+) / `team_invites_write` (admin). Baseline idempotente com dedup antes do índice único. |
| `20260911120000` | `0236_opt_in_de_chamada_de_voz` | A chamada de voz nasce DESLIGADA por organização (`org_voice_calls`), com quem aceitou o risco e quando. Ausência de linha é "desligado" — aplicar não liga nada para ninguém. Leitura org-flat, escrita de admin no banco. Baseline INSTALL/UPDATE idempotente. |
| `20260920002000` | `0350_catalogo_financeiro` | Primeira camada do módulo de comanda/financeiro: `financial_accounts` (onde o dinheiro fica), `payment_methods` (como o cliente paga — e cada forma APONTA para a conta em que aquele dinheiro cai) e `account_plans` (classificação, com `direction` in/out). A ordem é obrigatória: a forma de pagamento decide em qual conta a entrada é lançada quando a comanda é finalizada, então sem esta camada a comanda não tem onde depositar. **Nenhum saldo é gravado** — `opening_balance_cents` é o ponto de partida declarado, e o saldo corrente é sempre derivado por soma (saldo gravado e lançamentos divergem no primeiro estorno, sem dar sinal). Dinheiro em `_cents` + `currency`. `payment_methods.account_id` é `on delete restrict` de propósito: cascata deixaria formas apontando para o nada e lançamentos futuros sem destino, em silêncio. RLS tenant-aware com escrita de **manager+** (quem atende não define plano de contas). Nada disso existia no destino. |
| `20260920002100` | `0351_comanda_e_financeiro` | A comanda e o que ela move: `sales`, `sale_items`, `commission_rules`, `commissions`, `financial_entries`, `loyalty_ledger` — mais `fn_finalizar_comanda` e `fn_estornar_comanda`. A finalização faz **seis coisas numa transação** (venda, comissão por item, entrada na conta que a forma de pagamento determina, ponto de fidelidade, conclusão do agendamento), sob `FOR UPDATE` — sem o lock, duas finalizações simultâneas geravam lançamento em dobro. **Invariantes no schema, não na prosa:** nada é apagado (cancela/inativa); nenhum saldo é coluna (nem de conta, nem de fidelidade — é sempre soma); estorno é contra-lançamento com `reverses_entry_id`, nunca exclusão; comissão é resolvida na INCLUSÃO do item e congelada na linha, e a finalização não recalcula; numeração por organização não reinicia; e um trigger recusa UPDATE em lançamento já pago (valor/conta/direção/data). O CHECK `sales_finalizada_tem_forma` põe no schema a regra de que finalizar exige forma de pagamento. A conclusão do agendamento tem a guarda `status not in ('cancelled','no_show')` que o sistema de origem não tinha em todos os caminhos. |
| `20260920002200` | `0352_comanda_por_agendamento_e_unica` | Índice único parcial `sales (organization_id, appointment_id) where appointment_id is not null and status <> 'cancelled'`. A rota consulta antes de abrir, o que resolve o toque repetido mas não a corrida — e duas comandas para o mesmo atendimento não dão erro nenhum: são faturadas separadamente e o cliente paga duas vezes. Parcial nas duas pontas (avulsa é a maioria; cancelada deixa de valer, senão cancelar por engano trancaria o agendamento para sempre). Dedup antes do índice, mantendo a comanda mais antiga. |
| `20260920002300` | `0353_relatorio_financeiro` | `fn_relatorio_financeiro(org, de, ate)`: entradas, saídas, saldo, comandas finalizadas e estornadas, faturado, ticket médio, por forma de pagamento e por profissional. Agrega NO BANCO porque o PostgREST corta em 1000 linhas sem avisar — nesta mesma base a primeira medição do saldo devolveu R$ 141.436,00 em vez de R$ 641.103,60, e um relatório que erra assim é pior que um que não existe. `stable` e INVOKER (não definer): a RLS de cada tabela continua valendo, em vez de o isolamento ser reimplementado no corpo. Só lançamento `paid` entra; o estorno se desconta sozinho por ser lançamento de direção contrária. Revogada de `public, anon`. |
| `20260920002400` | `0354_regra_de_comissao_inativa` | `commission_rules.is_active` + índice parcial nas ativas. Sem isto a regra não podia entrar no catálogo financeiro genérico (que inativa em vez de apagar), e NÃO HAVIA porta nenhuma para cadastrá-la: toda comissão nascia 0% em toda instalação, e a precedência escrita na 0240 nunca era exercida. Inativar é o certo, não só conveniente — o percentual aplicado já está congelado no item, e o que se perderia ao apagar é a resposta a "por que aquela comanda saiu com este percentual". |
| `20260920002500` | `0355_saldo_de_fidelidade` | `fn_saldo_de_fidelidade(org, contato)`: `sum(points)` do livro-razão, `stable` e invoker. O saldo nunca é coluna (invariante 2 do módulo), e o que faltava era alguém poder fazer a soma. No banco pelo mesmo motivo da 0244 — o PostgREST corta em 1000 linhas sem avisar, e aqui o truncamento é pior que num relatório: vira prêmio negado a quem tinha direito, no balcão. Por CLIENTE, nunca agregado: o total geral esconde erros que se compensam, como a validação desta migração registrou. Revogada de `public, anon`. |
| `20260920002600` | `0356_relatorio_por_servico_e_cliente` | `fn_relatorio_financeiro` ganha `por_servico` e `por_cliente` (top 10 cada) sem mudar a assinatura. São as duas perguntas que decidem o que o negócio faz na semana seguinte, e faltavam. O serviço é agrupado pela DESCRIÇÃO congelada no item, não pelo nome atual do tipo — é o único agrupamento que continua verdadeiro depois de alguém renomear um serviço, e o item avulso entra em vez de sumir. O `limit 10` está nas LISTAS, nunca nos totais: resumir e truncar são coisas diferentes. |
| `20260920002700` | `0357_lancamento_recorrente` | `recurring_entries` (o molde) + `financial_entries.recurring_entry_id` + índice único parcial por (molde, competência). Aluguel, internet e salário não tinham conceito no destino, e a saída era lançar à mão todo mês — a que se esquece em fevereiro e faz o relatório parecer melhor do que foi. O molde NÃO movimenta dinheiro: quem nasce é uma linha PENDENTE, porque o sistema sabe que a conta vence e não sabe se alguém pagou. Mudar o molde não reescreve o que já foi gerado. A idempotência é do índice, não de uma flag de "último gerado" — esta falharia em duas execuções simultâneas. `day_of_month` 1 a 31, e o que não existe no mês cai no último dia dele. RLS de escrita em `manager`: definir o aluguel é política. |
| `20260920002800` | `0358_preco_do_tipo_de_evento` | `calendar_event_types.default_price_cents`. O catálogo de serviços já é o de tipos de agendamento (decisão da 0240) e faltava o preço: no balcão o valor era digitado a cada item — onde o erro de dinheiro entra — e o faturamento em lote era impossível, porque abrir a comanda de um atendimento passado exige saber quanto ele custa. NULLABLE e sem default de propósito: nem todo negócio tem preço fixo, e vazio significa "digite na hora", o comportamento de antes. É SEMENTE, nunca preço final — o item continua congelando o seu `unit_price_cents` na inclusão. |
| `20260914220000` | `0252_aniversario_do_contato` | `contacts.birthday_md` (coluna gerada, mês e dia num inteiro: 914 = 14/09) mais índice parcial por organização. `birthdate` existia desde cedo e não acionava nada — dado que entra e não sai por lugar nenhum. A coluna existe para o cron `contact-birthdays` buscar por igualdade em vez de varrer a tabela; `extract` sobre `date` é immutable, que é o que a coluna gerada exige, e `to_char` não é. |
| `20260912200000` | `0239_registro_nao_fica_pendente` | Tipo de evento que ninguém consome não é fila: `event_log.status` nasce `pending` (DEFAULT) e nenhum drain seleciona tipo sem handler (`drain.ts` filtra `event_type in (handlers)`; o do agent-engine filtra `ai_agent.dispatch_requested`), então o registro acaba a vida `pending` — issue #753: 626 linhas, 8 tipos, `consumed_by` vazio. `fn_event_log_e_registro` (lista dos tipos que são REGISTRO, em um lugar só) + trigger `trg_event_log_marca_registro` BEFORE INSERT (`pending` → `done` no nascimento) + backfill do estoque. Não toca em `*_requested` (comando sem consumidor PRECISA ficar visível — #129) nem em tipo consumido (nasceria `done` e o handler nunca rodaria); `consumed_by` fica vazio de propósito. Catraca: `tests/unit/evento-de-fato-nao-fica-pendente.test.ts`. |
| `20260912010000` | `0237_criador_provisorio_sai_na_entrega` | Quem cria um tenant PARA OUTRA PESSOA sai dele quando essa pessoa assume. **SEGUNDA TENTATIVA** — a primeira deduzia a entrega de *"existe outro admin aceito"*, o que é falso numa organização que a pessoa abriu para si e depois deu admin a um sócio: em produção o dono do servidor perdeu o vínculo com a PRÓPRIA empresa e a tela respondeu "Você não tem nenhuma organização ativa". A causa era de INFORMAÇÃO, não de predicado. `fn_create_tenant_with_owner` JÁ compara `owner_email` com o e-mail de quem cria (inline, para escolher `interface_settings`) e não anotava — agora anota em `user_organizations.provisional_until_handover`, e só quando o tenant é de outra pessoa. Organização criada por `/signup` nunca passa por ali e nasce `false`. `fn_accept_team_invite` apaga, no aceite de um `admin`, apenas os vínculos MARCADOS — depois de gravar o do dono, nunca antes. **Sem expurgo retroativo, de propósito:** vínculo antigo não tem a marca e deduzi-la é o erro que custou o acesso de alguém; casos anteriores resolvem à mão. |
| `20260912220000` | `0240_credencial_de_enfeite_nao_derruba_a_leitura` | `public.fn_decrypt_oauth` era `return pgp_sym_decrypt(ciphertext, k)` — decifrava QUALQUER bytea. Como as colunas cifradas são NOT NULL, "ainda não configurado" é gravado como byte de enfeite (`Buffer.from([0])`, o que `lib/waha/webhook-auth.ts` já documentava), e toda leitura que caía nesse registro estourava `39000 Wrong key or corrupt data` — issue #754: 10 a 30% das chamadas do RPC em 500, continuamente, nos mesmos registros. A guarda de forma é medida, não escolhida: pacote menor que 66 bytes (o menor que `fn_encrypt_oauth` produz, texto vazio) ou sem o bit 7 do primeiro byte (pacote PGP real medido: `0xC3`) devolve `null` — o contrato que os leitores já tratam — e a checagem de tamanho vem antes do `get_byte()` porque este estoura em bytea vazio. Cifra de verdade com a chave trocada CONTINUA levantando: é misconfiguração e tem de aparecer. Não reescreve linha nenhuma e não reabre ACL (segue só `service_role`). Catraca: `tests/unit/credencial-de-enfeite-nao-derruba-a-leitura.test.ts` + invariante com Postgres de verdade. |
| `20260914150000` | `0241_rascunho_de_agente_sem_numero` | `ai_agent_versions.channel_session_id` deixa de ser NOT NULL: o rascunho do agente existe antes de haver WhatsApp pareado (instalação nova não tem canal, e o editor exigia o número para salvar). Publicar sem número continua recusado por `fn_publish_ai_agent_version`. Sem backfill. |
| `20260914160000` | `0242_aviso_de_caso_parado` | Kind novo em `agent_inbox_items`: `case_stale` — o caso em `awaiting_human` que ninguém abriu há 24h volta a pedir passagem na Central (`ref_kind='agent_case'`). Nada avisava que um caso estava parado, e o cliente espera do outro lado: medido num CRM em produção com o mesmo desenho de fila, **22 pedidos esquecidos, o mais antigo há 17,6 dias**, numa fila que era usada (72 de 102 resolvidos). O CHECK é recriado INTEIRO (`drop if exists` + `add`) porque `add constraint` não é idempotente e não há `alter constraint ... add value` para CHECK — mesmo formato da 0233. Índice parcial `(organization_id, ref_id) where kind='case_stale' and status='open'`, que é a única pergunta que o watcher faz. Sem backfill: nenhuma linha usa o valor novo. Habilita o cron `case-stale-watcher`, que também dá a `agent_cases.followup_attempts` o escritor que ela nunca teve. |
| `20260914170000` | `0243_chamada_de_api_tem_prazo_de_trava` | `alter role authenticator/authenticated set lock_timeout='4s'`. No Postgres `lock_timeout` é **0 por padrão — esperar para sempre**, e o cliente HTTP do produto desiste em 10s (`DEFAULT_TIMEOUT_MS`). Juntas, as duas coisas produzem um defeito que nenhuma tem sozinha: aos 10s o navegador mostra "Erro inesperado" (sem identificador — o erro é DELE, não do servidor), **a consulta continua viva segurando a fila**, o botão volta a aceitar clique, e o seguinte empilha atrás. Medido em produção em 2026-09-12 pelo "Enviar link ao cliente" (`fn_meet_action`): dez chamadas simultâneas, Postgres a **357% de CPU** com load 7,33, app e worker em 0,16%; cancelá-las devolveu o banco a 1,47%. No PAPEL e não na função porque a varredura que veio junto (`tests/invariants/chamada-de-api-tem-prazo-de-trava.test.ts`) achou **sete** irmãs com a mesma forma — `fn_reply_action`, `fn_mesclar_contatos`, `fn_reserve_channel_connection`, `fn_set_channel_routing`, `fn_lgpd_anonymize_contact`, `fn_google_resolve`, `fn_google_selection` — e consertar uma a uma deixaria toda função futura nascer com o defeito. `service_role` fica de fora: trabalho de fundo pode esperar. 4s é abaixo dos 10s do cliente (para a recusa chegar à tela) e muito acima de uma operação legítima. Estouro levanta `55P03`, traduzido pelas rotas. |
| `20260914180000` | `0244_tags_de_conversa_em_uso` | `fn_tags_de_conversa_em_uso(p_org)`: o seletor de etiqueta do Inbox passa a oferecer as etiquetas **em uso** nas conversas, unidas ao vocabulário canônico. Medido numa instalação real: o seletor oferecia 8 etiquetas de semente, nenhuma conversa tinha etiqueta, filtrar por qualquer uma devolvia zero, e a etiqueta que a lista **exibia** não estava entre as 8. **`security INVOKER`, e isso é o ponto:** a função recebe a organização por argumento e é concedida a `authenticated`, então `definer` aqui seria leitura cross-tenant — a classe de defeito que a 0149 consertou em `emit_event`/`retrieve_top_k_chunks`, e que o comentário de `fn_gasto_de_ia_do_mes` descreve por extenso. Sob invoker quem isola é a RLS de `conversations`. Como isso tira a função do alcance da varredura de `definer`, o gate dela é `tests/invariants/tags-em-uso-aparecem-no-filtro.test.ts`, com isolamento entre duas organizações e recusa ao `anon`. Sem coluna, sem constraint, sem backfill; reaproveita `idx_conversations_tags_gin`. Baseline idempotente (`create or replace` + `revoke`/`grant`). |
| `20260914195200` | `0245_followup_stale_nao_e_retry` | `followup_stale` deixa de ser SQLSTATE `40001` (serialization_failure). Esse código pede RETRY da transação; o patch de follow-up usava a mesma mensagem para revisão velha e enrollment sumido — estados permanentes. Medido: 6000 erros/min, CPU 100%, mais de 24h, convoy de advisory lock num único contato. Passa a `P0001`. Catraca: `tests/unit/followup-stale-nao-e-retry.test.ts`. Baseline: as duas funções (`fn_followup_revision`, `fn_followup_patch`) no corpo canônico. |
| `20260912210000` | `0247_indices_fks_mensagens_e_runs` | **Índices parciais em foreign keys de `messages` e `ai_agent_runs`.** Previne sequential scans inteiros na tabela filha durante deleções e cascateamentos em contatos, sessões de canal e mensagens (especialmente expurgo LGPD). Índices parciais idempotentes `idx_messages_contact_id`, `idx_messages_channel_session_id`, `idx_ai_agent_runs_contact_id`, `idx_ai_agent_runs_channel_session_id`, `idx_ai_agent_runs_conversation_id`, `idx_ai_agent_runs_inbound_message_id`, `idx_ai_agent_runs_outbound_message_id`. |
| `20260914090000` | `0248_caso_tem_assunto` | `agent_cases.kind` — do que o caso trata, para triar a fila antes de ler. O assunto vivia só em texto livre (`title`/`summary`/`blocker`), o que basta com fila curta e não basta com volume: "quer marcar horário" e "está reclamando" pedem pessoas e urgências diferentes. **Sem CHECK, pela doutrina de vocabulário ABERTO** — o vocabulário útil muda com o nicho (clínica tem "remarcação", loja tem "troca"), e um CHECK fixo exigiria migration por nicho e quebraria o `update.sh` de clone com valor próprio; quem prende é `TIPOS_DE_CASO` no TypeScript, e a coluna fica fora do invariante `vocabulario-banco-x-typescript` (que só cobre coluna com CHECK). `not null default 'outro'` dispensa backfill. Índice parcial `(organization_id, kind) where status in ('awaiting_human','awaiting_lead')`, porque é nos abertos que se tria. |
| `20260914190000` | `0249_agenda_prazo_de_expiracao_do_pendente` | Terceiro prazo em `organizations.settings.agenda`: `pending_expires_after_minutes` — quanto tempo um pedido não confirmado segura o horário. **Por que uma migration para um campo de jsonb:** `fn_agenda_settings` não faz merge, ela ENUMERA as chaves aceitas e rejeita extras (`(p_config - 'a' - 'b') <> '{}'` levanta `22023`); sem recriá-la a tela salvaria o campo e receberia "não foi possível alterar os prazos". O campo é **opcional** de propósito — toda organização instalada tem duas chaves, e exigir três quebraria o PATCH vindo de uma aba aberta antes da atualização; ausente = default do TypeScript (1440). Piso de 15 minutos no corpo, porque abaixo disso a expiração corre com quem está decidindo. Sem backfill: a ausência já é estado válido. Habilita o cron `agenda-expira-pendentes`. **O corpo é DERIVADO da versão em vigor**: a função ganhou o portão `fn_session_mfa_proven()` na 0229, e recriá-la a partir do corpo antigo o apagaria — no baseline isso vira remoção de proteção no `update.sh` de quem já rodava. |

| `20260914200000` | `0250_guarda_contra_replay_do_gateway` | Hook `pgrst.db_pre_request` que responde 409 a requisição cujo `sb-request-id` (UUIDv7) tem >5 min. O gateway do Supabase reexecuta 5xx sem limite; `fn_followup_patch` com revisão obsoleta (`40001` → HTTP 500 no PostgREST) foi reexecutada ~2.300×/s por dois dias, esgotou o pool, o schema cache não carregou e todo o app respondeu 503 PGRST002. 4xx não é reexecutado; o loop morre na hora. Só registra o hook onde o papel `authenticator` existe. |
| `20260914210000` | `0251_acesso_da_ia_volta_a_trilha_do_operador` | **Forward-fix da 0218 (issue #602): a RPC gravava o LITERAL `'pre_go_live'` em `ai_gate_mode` em toda chamada** — abrir o canal ao público limpava o `ai_gate` e deixava o marcador de teste para trás. Enquanto o canal ficava aberto era inerte (o discriminador é `ai_gate`); na volta virava falha fechada e silenciosa: o script CLI do allowlist POR ORIGEM (`scripts/ativar-gate-elegibilidade-ia.ts`), que escrevia só `{ai_gate}`, devolvia o canal ao pré-go-live com a lista de testadores antiga, e o preflight do próprio script prometia `autorizado` onde o motor executava `fora_da_lista_de_teste`. Agora `ai_gate_mode` recebe o modo REAL (`p_modo`, no vocabulário do contrato da tela: `open`/`pre_go_live`). `create or replace`, sem DDL novo e sem backfill — o estado antigo é inerte e sai na próxima gravação da tela ou do script. |

| `20260921030000` | `0368_redes_sociais_nativas` | Redes sociais no Inbox, identidade separada e credencial cifrada exclusiva do servidor. |
| `20260915000000` | `0254_lembrete_em_degraus` | O lembrete deixa de ser um só. `calendar_event_types.reminder_extra_offsets_minutes integer[]` guarda os degraus ADICIONAIS (o principal continua em `reminder_minutes_before`), e `calendar_appointments.reminder_sent_offsets_minutes integer[]` passa a ser a autoridade sobre o que já saiu — com `reminder_sent_at` como filtro, quem recebeu o aviso de um dia nunca voltaria para o de três horas. Faixa (15 min a 7 dias) e teto de 3 extras num CHECK via `fn_degraus_de_lembrete_validos` (immutable, porque CHECK não aceita subconsulta). Backfill marca o degrau principal onde já havia carimbo, senão a primeira varredura depois da atualização reenvia lembrete a todo compromisso já avisado. Aditivo: clone que atualiza recebe `'{}'` e se comporta como antes. |
| `20260915010000` | `0255_atualizar_nao_desliga_o_lembrete` | **Atualizar o CRM desligava os lembretes que o operador tinha ligado.** A 0194 corrigiu o histórico junto com o default, e o raciocínio valia naquele dia ("zero leitores e zero disparador"); ela mesma avisou que "depois do disparador, isto seria apagar a escolha de um operador". O disparador nasceu (`agenda-reminder`) e o `update.sh` re-aplica o baseline inteiro — então toda atualização desligava tudo, sem erro, sem log, com a tela mostrando o controle desmarcado. A guarda é o `column_default`: pelo VALOR é impossível distinguir "ninguém escolheu" de "o operador ligou", mas o default só difere de `false` antes da primeira aplicação da 0194 naquele banco. Ler o default ANTES de gravá-lo. |
| `20260915020000` | `0256_lead_do_ingest_nao_duplica` | `fn_nascer_lead_da_conversa`: cria o lead de entrada serializando por (organização, contato) com `pg_advisory_xact_lock`, e devolve NULL quando já existe aberto. O check-then-act de `nascimento-do-lead.ts` deixava mensagens simultâneas do mesmo contato virarem vários cards — medido em produção: três mensagens seguidas, três negócios às 17:07, mesmo funil e mesma etapa. NÃO é índice único de propósito: `unique (org, contact) where status='open'` resolveria a corrida e quebraria o caso legítimo de dois negócios abertos criados à mão. A função não decide funil, etapa nem título: recebe tudo pronto, para a política continuar em TypeScript. |
| `20260915120000` | `0257_app_da_meta_da_instalacao` | **A credencial do APP da Meta sai do `.env` e vira linha de INSTALAÇÃO** (`public.platform_meta_app`): o App Secret que assina a entrega do webhook e o verify token que responde ao handshake. O `.env` só se edita por SSH em quem instalou, e o App da Meta é UM por instalação atendendo N WABAs de N organizações — a partir do 2º número não havia como configurar o app sem mexer no que já funcionava (issue #850). Mesmo objeto e mesmo desenho de `platform_google_oauth` (0201) e `platform_branding` (0155): singleton (`check (id = 1)`), RLS ligada, `revoke` de `anon`/`authenticated`, leitura e escrita só pelo `service_role` atrás do gate administrativo, e `updated_by` para auditoria (`platform_meta_app.updated` / `.verify_token_rotated`). O **verify token passa a ser GERADO NO SERVIDOR** (`randomBytes`, exibido uma única vez) e guardado com a cifra que o repo já usa para segredo de canal (`fn_encrypt_oauth`, AES-256 com a chave na GUC) — sem criptografia nova; `verify_token_created_at` guarda a rotação. **O `.env` NÃO é apagado: ele é o PISO de rollback** (o `agent.sh` reverte só a imagem, então código NOVO sobre banco sem a 0257 é o caminho que dói) e a rota de verificação lê o BANCO PRIMEIRO, caindo para o ambiente quando não há par completo — as duas fontes **não se misturam**, porque segredo de um lado com token do outro é um app que não existe e falha calada em `401`. Sem migração de dados do `.env` para o banco, por decisão do mantenedor. Baseline INSTALL/UPDATE idempotente. Catracas: `tests/unit/app-da-meta-credencial-do-banco.test.ts` e `tests/unit/webhook-meta-le-do-banco.test.ts`. |
| `20260915140000` | `0258_audit_log_so_recebe_linha` | `revoke update, delete, truncate on api_audit_log from public, anon, authenticated, service_role`. **O "append-only é do schema" era falso no Supabase real.** Todo projeto Supabase nasce com default ACL de TABELAS em `public` (`anon=arwdDxt`, `authenticated=arwdDxt`, `service_role=arwdDxt`), então `api_audit_log` nascia com UPDATE, DELETE e TRUNCATE para os três; o `GRANT SELECT,INSERT,REFERENCES,TRIGGER,TRUNCATE` enumerado do dump só acrescenta. Medido no Supabase local (pg 15.8): os três papéis com `DELETE,INSERT,REFERENCES,SELECT,TRIGGER,TRUNCATE,UPDATE`; num pg15 com o default ACL reproduzido, `set local role service_role; delete from api_audit_log …` → `DELETE 1`. Com a service key (que ignora RLS) uma linha escolhida da auditoria era apagada ou reescrita pela REST; `anon`/`authenticated` só não o faziam porque a RLS não tem policy de UPDATE/DELETE. A primeira versão desta migration (`0258_audit_log_perde_o_truncate`, nunca publicada em release) revogava só TRUNCATE e ficou verde porque o prelude do `test:db` reproduz o default ACL do Supabase para funções, não para tabelas. Não quebra ninguém: nenhum caminho do produto altera ou apaga linha desta tabela pelos papéis do PostgREST (o `delete` do `scripts/seed-e2e-capacidades.ts`, que falharia calado, saiu); o expurgo é `fn_expurgar_auditoria_vencida` (0167), `security definer` de dono `postgres`; as FKs `on delete set null` rodam como o dono da tabela. Vigiado por `tests/invariants/audit-log-sob-o-default-acl-do-supabase.test.ts`, que concede o default ACL do Supabase à tabela, reaplica o bloco extraído do baseline e sonda grants, privilégio efetivo, `permission denied` nos três comandos, INSERT/SELECT, expurgo e FKs. |
| `20260915150000` | `0259_indices_redundantes_saem` | Três índices cujo trabalho já é feito por outro, integralmente. (1) `ai_models_provider_model_unique (provider, model_id)`, criado pela 0127, contra a constraint `ai_models_unique (provider, model_id)` do schema original — mesmas colunas, mesma ordem, os dois UNIQUE: é o "índice duplicado em ai_models" que o advisor de desempenho apontou numa VPS de cliente. Fica a constraint (dá nome à violação, aparece em `pg_constraint`, não cai com um `drop index` por engano); o índice sai, dentro de um guard que confere que a constraint existe — num clone onde ela tenha sido removida à mão, o índice da 0127 é a ÚNICA coisa impedindo cadastro duplicado de modelo. (2) `idx_crm_lead_links_lead (lead_id)` contra `uniq_crm_lead_links_lead_target_link (lead_id, target_kind, target_id, link_kind)` e (3) `calendar_connections_org_pessoa_idx (organization_id, user_id)` contra `calendar_connections_conta_key (organization_id, user_id, provider, account_email)`: um btree responde por qualquer PREFIXO das suas colunas, então o de quatro atende tudo que o de um ou dois atendia. Índice redundante custa em todo insert/update e ocupa disco. **O planner não os ignorava**: medido em pg17 (20 000 vínculos, 2 000 leads), a busca por `lead_id` usava o de uma coluna quando ele existia (216 kB, custo 4,36) e passa a usar o de quatro (1464 kB, custo 4,49) — segue servida por índice; troca-se índice menor na leitura por um índice a menos em toda escrita. **Os três drops têm guard**, e não só o 1: o `update.sh` roda sem `ON_ERROR_STOP`, e "o largo é declarado no mesmo baseline" não prova que ele existe num clone onde a criação falhou em silêncio. **O baseline deixou de criá-los para derrubar no fim**: saiu a linha do dump (`idx_crm_lead_links_lead`) e a do bloco do calendário, e o bloco da 0127 só cria o seu índice onde a constraint `ai_models_unique` falta. Antes, toda instalação e todo `update.sh` construía os três (`CREATE INDEX` não concorrente, que trava escrita enquanto constrói) e os jogava fora no apêndice. Estado final igual ao da cadeia (0127 cria, 0259 derruba). |

| `20260921030100` | `0369_prospeccao_nativa` | Busca com orçamento, candidatos únicos por organização e campanhas graduais com pausa e histórico; acesso exclusivo pelo servidor autenticado. |
| `20260921030200` | `0370_prospeccao_anonimizacao` | Cascata canônica apaga telefone e enriquecimento; tokens pseudônimos restritos ao servidor impedem reimportar a mesma origem ou telefone após anonimização. |

| `20260921030300` | `0371_prospeccao_conversa_salva` | A conversa e a proposta do agente ficam na campanha, separadas da configuração ativa. Revisão monotônica impede respostas atrasadas e abas concorrentes de sobrescrever o resumo. Acesso permanece exclusivo pelo servidor autenticado; sem envio, roteamento ou backfill. |
| `20260915160000` | `0260_ocupacao_do_google_do_dono` | **A ocupação do Google Agenda do dono não depende de quem consulta** (issue #879, PR #883). A coleta de horários chegava aos eventos do Google pelo embed `calendar_connections!inner`, e a RLS da conexão (dono ou `manager`+, porque guarda token OAuth) escondia a conexão de um `agent`: medido, a mesma agenda rendia 1 evento ao dono, 1 ao gerente e 0 ao atendente, e a grade e o encaixe ofereciam horário em cima de compromisso que existe. Duas `security definer` estáveis — `fn_agenda_ocupacao_google_do_dono(p_org, p_owner, p_de, p_ate)` e `fn_agenda_conexoes_google_do_dono(p_org, p_owner)` — atravessam SÓ essa RLS, conferem o pertencimento no corpo (`fn_user_org_ids()`, com `auth.uid() is null` para o `service_role` e `fn_is_platform_admin()` pela paridade com a policy), filtram o dono e devolvem ocupação (início, fim, transparência, situação do evento e da conexão) — nunca título, descrição ou participantes. **Por que função e não o client admin do PR:** `lib/supabase/admin.ts` proíbe service role em rota de usuário, e o admin ficava escondido numa coleta que recebe o client de fora. EXECUTE revogado das duas origens (`public`, `anon`). **Medido por sabotagem:** trocar a conferência de pertencimento por `true` reprova o caso cross-org da função de conexões e NÃO o da de ocupação — ali a view `calendar_selected_external_events` já filtra por `fn_google_counts_for_conflicts`, que confere o pertencimento de quem chama; a guarda do corpo é defesa em profundidade na de ocupação e a única guarda na de conexões. Sem coluna, sem constraint, sem backfill. Número 0260 porque 0258/0259 estão tomados pelo lote 9 em integração. Gate: `tests/invariants/agenda-ocupacao-google-do-dono.test.ts`. |
| `20260915170000` | `0261_titulo_do_evento_pessoal_sai_do_alcance` | **O título do compromisso pessoal da agenda do Google sai do alcance do membro.** `calendar_external_events` é o espelho da agenda PESSOAL de quem atende, o papel `authenticated` tinha SELECT de TABELA nela, e a view `calendar_selected_external_events` era `select e.*` — com o `title` dentro. Medido num banco instalado do zero (baseline da v1.26.0), com o título inserido à mão: um membro de outro papel, inclusive Somente leitura, lia o `title` da linha do colega — `select title from calendar_external_events …` → `"Terapia sigilosa"` — tanto direto na tabela quanto pela view, e `has_column_privilege('authenticated','calendar_external_events','title','SELECT')` respondia `true`. **Alcance real: o privilégio estava aberto em toda instalação; o nome, só em agendas sincronizadas antes da v1.17.0.** Desde a 0225 o sincronizador grava `title` nulo, e a 0225 não anulou as linhas antigas: sobra o que a ressincronização ainda não regravou (o rebuild completo, a cada 24h, cobre de 1 dia atrás a 90 dias à frente; o passado espera `fn_expurgar_espelho_da_agenda`, por padrão 90 dias após `ends_at`; a agenda que o sincronizador não lê não é regravada, futuro inclusive — conexão que não está saudável, membro revogado e agenda fora do catálogo do Google (a reserva é recusada ou não sai, medido no invariante), e agenda desmarcada, que o cron adia sem ler (`app/api/v1/cron/agenda-google-sync/route.ts`); e, DENTRO da janela, o evento cancelado escapa do rebuild — o `page` final apaga o não visto com `and status<>'cancelled'` e a leitura completa do Google não devolve cancelados —, guardando o nome até o expurgo mesmo sendo futuro, medido no invariante com o controle de que o confirmado que sumiu é apagado). O conserto fecha esse resíduo e vale como defesa em profundidade contra um escritor futuro. **O conserto é no privilégio; a policy de leitura segue da organização, e esta migration não a toca.** O SELECT de tabela sai e volta COLUNA A COLUNA, sem `title`; revogar a coluna sem revogar a tabela não faria nada, porque o privilégio de tabela cobre todas as colunas — e ele vem do default ACL de TABELAS do Supabase (`ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES`, que o dump também reemite), não de um `GRANT` desta tabela, que o dump não tem. **A view é recriada com `drop` + `create`**, e não com `create or replace`: o Postgres recusa tirar coluna do meio, e com `security_invoker` um `e.*` já expandido faria TODA leitura de ocupação por membro falhar com `permission denied` no `title`. A lista explícita é o conserto de fundo — `e.*` era a forma de a próxima coluna nascer exposta. `service_role` fica intocado e mantém SELECT/UPDATE na coluna, mas nenhum caminho do produto os usa: o sincronizador grava pela `fn_google_calendar`, `security definer` executável só por `service_role`, com o privilégio do DONO da função — e grava `title` nulo desde a 0225 (v1.17.0): insere `null` e, no `on conflict`, faz `set title=null`, zerando o que encontra —, e o único privilégio de tabela do `service_role` que o produto usa é o DELETE da desconexão, com SELECT nas colunas do filtro (medido no invariante: título nulo, igual com todo privilégio de tabela do papel revogado, e a desconexão apagando); nenhuma função nem view que `authenticated` ou `anon` alcance cita o título ou a linha inteira do espelho — o grant de coluna fecha o login, e uma `security definer` com EXECUTE para `authenticated` devolvendo `e.title` entregava o título ao colega com todas as asserções de privilégio verdes (medido), então o invariante varre `pg_proc` e as views de `public` e reprova a alcançável; a única que cita o título é `fn_google_calendar`, só `service_role`; `anon` segue sem privilégio nesta tabela desde a 0177; e esta migration não apaga os títulos que sobraram de sincronizações anteriores à v1.17.0: o que se fecha é a LEITURA por login de usuário — do colega e também do próprio dono, já que nenhuma tela o mostra (vigiado por `tests/unit/ocupacao-do-google-nao-expoe-titulo.test.ts`, que casa a tabela e a view, e por `tests/e2e/agenda-ocupacao-do-google-na-grade.spec.ts`). Coluna nova no espelho nasce sem SELECT para `authenticated` e exige decisão explícita. **O `title` não é o único dado pessoal do espelho, e o que fica aberto está escrito:** `external_calendar_id` segue concedido a `authenticated` e, na agenda principal do Google, é o e-mail da conta conectada — o colega o lê pela tabela e pela view enquanto a RLS de `calendar_connections` esconde a conta dele (medido no invariante); `external_event_id` também segue concedido, e `ical_uid` — que não é identificador do Google, e sim o UID RFC 5545 gerado pelo sistema de quem criou o evento (`lib/agenda/google/evento.ts`) — é resíduo do mesmo período do `title` (só o cron anterior à v1.17.0 o gravava) e, ao contrário dele, NÃO é limpo ao ressincronizar: o `on conflict` de `fn_google_calendar` não o põe no `set` (medido no invariante); esta migration também não o apaga. Revogar a coluna derruba toda leitura da view por membro, a do dono inclusive, porque a view `security_invoker` a passa a `fn_google_counts_for_conflicts`. O que fecha sem mudar leitura nenhuma é trocar a policy de SELECT por "dono da conexão OU manager ou acima" (a régua de `calendar_connection_calendars_select`): as duas leituras de tela (`app/app/agenda/page.tsx` e `app/api/v1/agenda/agendamentos/route.ts`) já pedem a view pela sessão com `calendar_connections!inner(user_id)`, cuja RLS esconde do não-gestor a conexão do colega — a grade de hoje JÁ não mostra ao Somente leitura e ao Atendente a ocupação do Google do colega (issue #879) —, e quem a entrega a todo membro é a `security definer` da 0260, que policy nenhuma alcança; o gestor já lê `account_email` em `calendar_connections`. Medido numa transação desfeita: não-gestor com 0 linha na tabela, na view e na tela, a função da 0260 com a ocupação, dono e gestor com a tela inteira. Muda QUEM lê o espelho: decisão do dono, fora deste conserto. (Uma versão anterior desta linha dizia que a policy era da organização de propósito, porque a grade mostraria a ocupação do colega; não mostra.) Vigiado no banco por `tests/invariants/titulo-do-evento-pessoal-fora-do-alcance.test.ts`: o estado da v1.26.0 reconstruído como controle; o bloco aplicado POR CIMA dele (o caminho do `update.sh` de um clone antigo) — `permission denied` no título, a leitura da tela de pé para o dono e o gestor (e vazia para o colega não-gestor antes e depois do bloco), a função da 0260 devolvendo a ocupação ao colega, view sem `title`, o `service_role` chamando o sincronizador, que grava `title` nulo pela definer — igual sem nenhum privilégio de tabela do papel —, e a desconexão apagando com o DELETE do papel (com controle de que o papel medido é o real) e o colega lendo o id do calendário; o banco instalado SEM reaplicar o bloco (o único que enxerga um grant de tabela posterior, e onde mora a varredura de funções e views alcançáveis, com controle de que ela pega três leitores do título e deixa passar quem só lê ocupação ou só o `service_role` executa); e o default ACL do Supabase simulado na transação antes do bloco (`alter default privileges`, com controle de que é a simulação, e não o dump, que chega à view recriada) — a view fechada para `anon`, o colega sem título pela tabela e pela view, o dono com a ocupação que a tela dele lê e sem título, e `fn_agenda_ocupacao_google_do_dono` (0260) devolvendo a ocupação. Os tipos de `lib/database.types.ts` tiram `title` da view. Baseline INSTALL/UPDATE idempotente. |
| `20260915180000` | `0262_cliente_pela_agenda` | **Cliente pela agenda, ligado por organização (PR #867, @423313).** `contacts.first_service_at` = `min(least(created_at, starts_at))` dos agendamentos cujo status conta (`fn_situacao_conta_como_atendimento`, espelho de `LIBERAM_O_HORARIO`: cancelado e falta não contam) — a data do primeiro horário que conta — o dia em que se combinou, ou o dia do atendimento quando ele for mais antigo —, nunca uma data futura. Recalculada por `trg_agendamento_marca_cliente` (INSERT), `trg_agendamento_recalcula_cliente` (UPDATE de status/starts_at/contact_id) e `trg_agendamento_apagado_recalcula_cliente` (DELETE) SÓ com `settings.crm.cliente_pela_agenda = true`. A etiqueta `cliente` tem dono (`contacts.client_tag_by_system`: `added`/`removed`/null, com CHECK): o sistema só tira a que pôs e só repõe a que tirou; a da equipe não se mexe. O dono é lido NA ESCRITA da etiqueta, pelo BEFORE UPDATE `trg_contato_colunas_de_cliente` (`fn_colunas_de_cliente_sao_do_sistema`) — que também RECUSA (42501) a escrita de sessão nas três colunas novas, medido antes: um `viewer` da própria organização gravava `first_service_at = '2019-01-01'` com `UPDATE 1`. `fn_definir_cliente_pela_agenda` devolve um quarto número, `com_agendamento_que_nao_conta`, sem o qual a tela dizia "nenhum contato tinha horário marcado" a uma agenda só de cancelamentos. `contact.tag_added` por `emit_event`, no formato do app, UMA vez por contato (`contacts.client_recognized_at`, que nunca volta a null); o repontamento de `fn_mesclar_contatos`, a ligação da regra e o DELETE nunca emitem. `fn_definir_cliente_pela_agenda` (admin, suporte de escrita, MFA; EXECUTE revogado de `public` e `anon`) grava a chave e classifica o histórico SÓ da organização que liga, sem evento por contato. Ordem de travas única: advisory da organização (compartilhado no trigger e em `fn_mesclar_contatos`, redefinida aqui só para pegá-lo antes dos contatos; exclusivo na ligação), depois o contato em `for no key update` — com `for update` e sem a trava na fusão, uma marcação comum e uma fusão fechavam `deadlock detected` com a ligação (medido). Nenhum backfill de classificação no apêndice; o único UPDATE de dados carimba `client_recognized_at` onde já havia `first_service_at` (zero linhas em instalação que nunca teve a coluna). `crm_pipelines.is_client_pipeline` + índice único parcial espelham `is_default`. As versões anteriores (backfill para todas as organizações, cancelado contando, etiqueta reposta, evento a cada virada) nunca estiveram em release nem na main. Gate: `tests/invariants/cliente-nasce-do-agendamento.test.ts`. |
| `20260919151000` | `0328_mensagem_do_lembrete` | O texto do lembrete no WhatsApp passa a ser do tipo de agendamento (`calendar_event_types.reminder_body`). NULL = a frase padrão do cron, o comportamento anterior. Distinto de `reminder_template_name` (nome do template no provedor oficial). Aditiva e nullable; sem backfill. Superfície na tela de Tipos de agendamento. |
| `20260919151100` | `0329_mensagem_por_lembrete` | Cada lembrete do tipo passa a ter texto próprio. `calendar_event_types.reminder_bodies jsonb` guarda o mapa minuto→frase dos degraus ADICIONAIS (`reminder_body` continua sendo o do principal). O teto de 3 extras da 0254 sobe para 20 em `fn_degraus_de_lembrete_validos` — quem opera escolhe quantos avisos o cliente recebe; 20 é guarda contra laço de formulário. Backfill copia `reminder_body` para cada extra já existente, para a atualização não trocar o texto que já saía. CHECK `calendar_event_types_corpos_validos` via `fn_corpos_de_lembrete_validos`. Superfície: lista dinâmica em Tipos de agendamento. |
| `20260919151200` | `0330_enderecos_salvos_da_agenda` | Endereços reutilizáveis ao marcar (`calendar_locations`): salas e unidades da ORGANIZAÇÃO, unique por org + endereço normalizado. A tela de novo agendamento filtra a lista ao digitar e oferece salvar um endereço novo para os próximos horários. Leitura de membro, escrita `agent+`. Sem backfill: a lista nasce vazia e cresce com o uso (e também sugere o que já está em `location_details` de tipos e compromissos). |
| `20260918120000` | `0310_csv_como_material_de_conhecimento` | **CSV vira um formato de material de conhecimento — sem carregar SheetJS/exceljs.** `lib/ai/rag/extractors/csv.ts` reusa `decodificarCsv`/`parseCsv` de `lib/contacts/csv.ts` (mesma defesa de encoding pt-BR e mesma recusa de `.xlsx` renomeado para `.csv` que a importação de contatos já tinha) para converter cada linha em um bloco `Coluna: valor`, que o chunker trata como um parágrafo por registro. O bucket `ai-policy` (migration 0014) foi criado com `allowed_mime_types` fechado em PDF/Markdown/texto, e como o `insert` daquela migration é `on conflict (id) do nothing`, nenhum clone já instalado recebe mudança de MIME dela de novo — este `update` é o follow-up idempotente que abre `text/csv` para quem já tem o bucket. `.xlsx`/`.xls` continuam recusados na BORDA (rota de upload e `resolverExtensao`), com a mesma instrução de "exporte como CSV" que `lib/contacts/csv.ts` já ensinava — decisão deliberada de não adicionar dependência de planilha binária para um recurso que todo Excel já exporta como CSV. Extraída do PR #928 de @cabindaferreira, que a numerou `0218` — número já ocupado na `main`. Aqui é **0310**. O número desta migration já foi 0218 (do PR), 0271 e 0278: cada vez que ela esperou, a fila andou por baixo dela. Medido em 2026-09-18 cruzando `git ls-tree` de 902 refs locais com a `origin/main`: o maior número tomado em qualquer lugar é **0307** (0278–0283, 0290–0295 e 0305–0307 estão em branches represadas, nenhuma na main), então 0308 e 0309 ficam de folga e esta assume 0310. A folga é deliberada — renumerar custa mais que pular dois números. |
| `20260920020000` | `0363_recusa_permanente_da_agenda_nao_pede_repeticao` | **As três recusas PERMANENTES de `fn_meet_action` passam de `40001` para `PT409`** (recorte do PR #803, de @paulolimajr77). `meet_stale`, `meet_conversation_stale` e `google_conflict_requires_choice` não melhoram com nova tentativa — quem clicou precisa atualizar a tela —, mas `40001` promete o contrário, e quem acredita é a infraestrutura: o PostgREST mapeia a classe 40 para HTTP 500 e o gateway do Supabase reexecuta 5xx **sem limite** (o incidente de 2026-09-11, em `docs/runbooks/postgrest-replay-do-gateway.md`, pôs o produto inteiro em 503 `PGRST002` por esse caminho). `PT409` é a saída que o próprio runbook nomeia e que a guarda da 0250 já usa: o PostgREST lê os três últimos dígitos como status HTTP, e 4xx não é reexecutado. **Não** é `22023` (que já significa `meet_action_invalid` dentro desta mesma função) nem `P0001` (que sairia como 400). Corpo idêntico ao da `main`, com três `errcode` trocados e mais nada — mesma ordem de guardas, mesmas travas, `lock_timeout` seguindo no PAPEL (0243). Sem tabela, coluna ou dado novo; `create or replace`, re-aplicável. |
| `20260920130000` | `0365_reenviar_o_link_e_acao_propria` | **Reenviar o link do Meet passa a ser uma ação própria (`resend`), e o `deliver` continua protegido** (recorte do PR #803, de @paulolimajr77). Quem já enviou e precisa enviar de novo — cliente apagou a conversa, trocou de número — não tinha caminho pelo produto: o botão dizia "Link já enviado" e ficava desligado, e a saída era mandar o link à mão, fora do CRM, sem registro, sem fronteira de atendimento e sem auditoria. O `return false` do `deliver` em `waiting_for_link`/`sent` **não** foi afrouxado: ele é a proteção contra clique duplo, e perdê-la custaria envio em dobro ao cliente, que é pior que não-envio. Então `deliver` fica idêntico e `resend` é caminho próprio que passa reto, atrás de confirmação na tela e de rota própria. `waiting_for_link` e `queued` continuam trancando os dois (ali a entrega já está a caminho). O `motivo` da entrega fica FORA desta migration de propósito: quem o consome é `fn_meet_delivery_enqueue`, que só passa a copiá-lo na fatia da remarcação — gravá-lo agora seria evento sem consumidor. Empilha sobre a 0363: mesmo corpo, com o ramo do envio ampliado. `create or replace`, re-aplicável, sem tabela/coluna/dado novo. Invariante novo: `tests/invariants/reenviar-o-link-e-acao-propria.test.ts`. |
| `20260920140000` | `0366_o_compromisso_chega_sem_meet` | **O compromisso chega ao cliente mesmo sem Google Meet** (recorte do PR #803, de @paulolimajr77). A entrega transacional já resolvia fila, canal, fronteira de atendimento e autorização — ela só não valia para compromisso PRESENCIAL ou POR TELEFONE, porque `meeting_state='ready'` e `meeting_url is not null` eram exigências INCONDICIONAIS em `fn_meet_delivery_current`. Passam a valer só onde `location_kind='google_meet'`. E `fn_meet_action` perde a guarda `location_kind<>'google_meet'` da recusa por `meet_stale`: com ela, autorizar o envio de um compromisso presencial era recusado como se a tela estivesse velha. **A guarda de link NÃO vai para `fn_meet_action`**, e o autor deixou medido por quê: ele tentou, e ela derrubou 10 casos legítimos do invariante do Meet — a tela oferece "Enviar quando ficar pronto" e a entrega fica em `waiting_for_link` até o link chegar; quem garante que reunião sem porta não sai é o enfileirador. O que NÃO muda: atendimento aberto na conversa, contato anonimizado/bloqueado fora, papel conferido no ENVIO (não no clique), e link obrigatório onde o local é o Meet. Duas `create or replace`, sem tabela/coluna/dado novo, re-aplicáveis; apêndice do baseline acima da varredura de anon. |
| `20260921050000` | `0374_remarcar_corrige_o_envio` | **Remarcar um compromisso JÁ ENVIADO passa a corrigir o cliente sozinho** (recorte do PR #803, de @paulolimajr77). O autor mediu a cadeia peça por peça: o gatilho de remarcar sobe a revisão e não toca em `meeting_delivery`; o de enfileirar só age em `waiting_for_link`; `fn_meet_action('deliver')` devolve false em `sent`; e a tela desabilitava o botão. Resultado: o cliente recebia um horário, alguém remarcava, **nada saía**, e a pessoa aparecia no dia errado — nenhuma varredura cobria (a `fn_appointment_confirmation_sweep` só age DEPOIS do compromisso e cria aviso interno, nunca mensagem ao cliente). Gatilho novo `trg_remarcar_corrige_o_envio` (BEFORE UPDATE) com três guardas contra virar spam: só quem está em `sent` (em `waiting_for_link`/`queued` a mensagem ainda sai com o horário novo sozinha), só quando `starts_at`/`time_zone` mudam (senão editar o TÍTULO mandaria mensagem), e espera de 2 min em `nao_antes_de` — com a remarcação seguinte SUBSTITUINDO a anterior, porque o enfileirador passa a matar o job pendente da geração anterior. A autorização é herdada de quem autorizou o envio original (mesma intenção, corrigida) e a vigência **reconfere** papel e responsável no envio. `fn_meet_delivery_enqueue` passa a carregar `motivo` no payload (ausente = `primeiro_envio`, o comportamento antigo) e a respeitar `nao_antes_de` no `run_after`; NÃO recria o gatilho, para não reabrir a janela de apagar-e-criar da Onda 1. Sem tabela, coluna ou dado novo. |
| `20260919140000` | `0324_destinos_internos_da_instalacao` | **A lista de destinos internos que o dono da instalação autoriza sai do `.env` e ganha tela (decisão 22-d, #1004; PR #1055, @webtecnica).** `platform_settings.internal_destinations text[]`: IPv4 e faixas CIDR IPv4 que a instalação pode alcançar mesmo sendo rede interna, editados em `/admin/destinos-internos`. `null` = a tela nunca foi usada e vale o piso `IA_DESTINOS_INTERNOS_PERMITIDOS` do `.env`; `'{}'` = o dono esvaziou a lista, e nada passa — os dois estados são distintos para o dono poder revogar pela tela o que o `.env` autorizou. Não guarda nome: a decisão compara o IP que o nome resolve (item 4 da #1004). A lista vale só para destino configurado pela INSTALAÇÃO (hoje, `TRANSCRIPTION_BASE_URL`) e dispensa só a recusa por endereço interno — esquema e `https` em produção continuam valendo (`lib/automation/destinos-internos-autorizados.ts`). A tabela já é só do servidor (0253); coluna nova herda RLS sem policy e os grants. Sem função nova, sem backfill, sem dado tocado. Apêndice do baseline idempotente. |
| `20260916202500` | `0267_espera_da_fila_nao_recomeca` | **A aba Fila deixa de mandar para o fim quem insiste (issue #990).** A ordem era por `last_inbound_at` — a **última** mensagem do cliente —, e essa coluna é reescrita a cada mensagem (`fn_mark_conversation_message` faz `greatest(last_inbound_at,p_at)`): a espera de quem pergunta de novo RECOMEÇA, e ele afunda atrás de quem escreveu uma vez e ficou quieto. Cenário da issue (A escreve 10h00, B escreve 10h05, A escreve 10h10) mostrava B na frente de A. A régua passa a ser **a mensagem do cliente mais antiga desde a última resposta enviada**, em coluna própria (`conversations.awaiting_since`) porque a ordem da Fila é um `order by` pedido ao PostgREST pela rota da lista, e o PostgREST ordena por COLUNA — a expressão mora em `messages` e depende de `last_outbound_at`. A decisão de ordenar por espera continua a de 16/09 (#639): muda a régua, não a decisão. Os TRÊS lugares que falam de espera passam a ler esta coluna — a ordem da lista, a pílula "Aguardando há…" da linha (`components/inbox/ConversationListItem.tsx`) e a posição entregue às ferramentas de IA (`lib/routing/queue.ts`, via o MCP de handoff) —, que antes divergiam: a pílula e a hora do canto liam `last_inbound_at` e mantinham o defeito na tela mesmo com a lista ordenada. `awaiting_since` carrega o valor de hoje (`last_inbound_at`) quando **não há mensagem sem resposta** — a bola está com o cliente —: são as linhas que nunca tiveram o defeito, e é deliberado, porque NULL ordena por ÚLTIMO e a conversa que hoje aparece no meio da fila cairia para o fim na atualização. Preenchimento em duas passadas: a primeira copia `last_inbound_at` para quem já está respondido (sem tocar em `messages`); a segunda gasta um `min()` por conversa **apenas** para quem tem mensagem sem resposta, servido por `idx_messages_conversation_sent (conversation_id, sent_at DESC)` — o custo caro é proporcional às conversas em espera, não ao tamanho de `messages`, e a guarda `awaiting_since is null` torna a reaplicação um no-op. Corpo de `fn_mark_conversation_message` DERIVADO da versão em vigor (lock de serviço, guarda de troca de contato, fronteira de `service_closed_at`), e `fn_reply_record_receipt` — a resposta aprovada escreve `last_outbound_at` à mão e não passa pela função de carimbo — recebe `awaiting_since=last_inbound_at` no bloco em vigor, senão a espera encerrada pela aprovação ficaria congelada. Sem constraint nova, sem índice novo, sem coluna gerada (a régua depende de `messages`, que muda depois). Gate: `tests/unit/fila-nao-empurra-quem-insiste.test.ts`. |
| `20260915213849` | `0264_vocabulario_de_tags` | O vocabulário de etiquetas deixa de ser só de leitura (issue #852, fatia S4). Três funções em `public`: `fn_vocabulario_de_tags` (leitura, `security invoker`, devolve `tag, uso_em_contatos, uso_em_leads, uso_em_conversas, em_regras, cor, descricao, no_vocabulario` a partir dos três `text[]` e das sementes de `organizations.settings`), `fn_tags_normalizar` (pura/imutável, renomeia/junta/exclui numa lista desduplicando por nome canônico — é o que impede a junção `{"vip","VIP"}` de virar duplicata) e `fn_vocabulario_de_tags_operar` (a operação inteira numa transação: os três arrays, as sementes da organização e as ações `add_tag` de `automation_rules`, com `fn_role_at_least(p_org,'manager')` antes de qualquer escrita e `emit_event` por linha alterada). Renomear e juntar reescrevem a regra do agente; excluir NÃO apaga a regra — informa quantas continuam escrevendo (decisão de outra tela). Sem tabela nova, sem coluna nova, sem backfill: o vocabulário continua sendo `text[]` + jsonb em `settings`, porque automação, webhook (`lead.tag_added`) e MCP (`*.tags_changed`) já falam em string. `revoke execute ... from public, anon` nas três e `grant` só a `authenticated`/`service_role`. |
| `20260917120000` | `0271_extensoes_declarativas` | Cinco tabelas do framework declarativo: catálogo admitido, artefato imutável, instalação por origem/identidade, vínculo por organização e recibo durável. Oito RPCs service-only (admitir, preparar, concluir, falhar, cancelar, configurar, desfazer a última troca, remover da instalação) com ator vigente, fingerprint idempotente, `applied_now` que distingue a transição da repetição, e CAS pela revisão da instalação; mais a contagem de organizações ativas por instalação, que devolve só números. A instalação ganha `previous_artifact_id` (histórico de um passo), `revision` e remoção lógica (`removed_at`/`removed_by`); o vínculo ganha `deactivated_by_removal_at`. As CHECKs de `kind`/`status` do recibo são constraints nomeadas com drop/add, então um banco de desenvolvimento com a versão antiga do bloco se corrige ao reaplicar o apêndice (a cadeia `db push` não reaplica). Publicar espera a atualização do core (`dispatched` com menos de 15 min); remover não espera. prepare/finish/cancel/revert/remove e o gatilho de system_update_runs compartilham a trava consultiva; configurar serializa com a troca de ponteiro por `for share` na instalação. RLS de membership somente para leitura do vínculo; escrita direta do framework revogada inclusive de service_role. Apêndice idempotente. |
| `20260915193743` | `0263_etapa_de_perda_grava_o_motivo` | **A etapa de PERDA do lote grava o motivo na MESMA escrita.** `fn_mover_leads_em_lote` (0209) trocava só o `stage_id`; quando a etapa de destino é de perda, `trg_crm_lead_close_on_stage` fecha o negócio e a CHECK `crm_leads_lost_reason_required` recusa a linha — e, como a função é uma transação só ("move todos ou não move nenhum"), **UM** card sem motivo derrubava o LOTE INTEIRO, com a rota respondendo 500 (`internal_error`) a quem acabara de pedir o movimento. Medido no baseline (pg16, lote de 2 cards abertos para a etapa de perda): `SQLSTATE=23514 | new row for relation "crm_leads" violates check constraint "crm_leads_lost_reason_required"`. O quarto parâmetro `p_lost_reason` é a **mesma escrita** — uma escrita ANTES tem janela (entre as duas o negócio aparece `lost` sem motivo) e uma DEPOIS é linha já recusada. A coluna só entra na lista do `update` quando há motivo (SQL dinâmico) porque tocar `lost_reason` dispara `trg_validate_lost_reason_required`, que confere o valor contra o vocabulário do funil: reescrever o valor que já estava na linha recusaria um card cujo motivo saiu da configuração depois de usado (22023 `lost_reason_invalid`) enquanto o arrasto do mesmo card — que não toca a coluna — continuaria passando. `coalesce` com o valor da própria linha preserva o motivo do card que já era perdido (`p_lost_reason` nulo não apaga nada). `drop function` antes do `create or replace` porque o PostgreSQL não substitui assinatura (sem ele, `fn_mover_leads_em_lote(uuid, uuid[], uuid) is not unique`). Sem coluna nova, sem backfill, sem dado tocado; baseline com o corpo final. Os **três caminhos** ficam com uma só decisão (`lib/leads/motivo-da-perda.ts`): arrasto (`/leads/[id]/move`), lote (`/leads/bulk`) e a IA (`lib/leads/agent-stage-sync.ts` — o agente **não** move: recusa de negócio com item de inbox acionável, porque `lost_reason` não é texto livre e o motivo é decisão de quem está no negócio). Catracas: `tests/unit/etapa-de-perda-no-arrasto.test.ts`, `tests/unit/etapa-de-perda-no-lote.test.ts` e `tests/unit/etapa-de-perda-do-agente.test.ts` (vermelhas sem o fix). |
| `20260919152000` | `0331_comportamento_da_instalacao` | **O COMPORTAMENTO da instalação ganha coluna e tela (issue #1034).** Quatro decisões que valiam para a instalação inteira e só se tomavam por SSH passam a morar na linha única de `platform_settings`: `orcamento_de_ia` (ex-`AI_BUDGET_ENFORCEMENT`), `exigir_assinatura_no_webhook` (ex-`WAHA_WEBHOOK_REQUIRE_SIGNATURE`), `divulgacao_de_pagamento` (ex-`DISCLOSURE_MODE`) e `promessa_semantica` (ex-`PROMISE_SEMANTIC_ENABLED`). As quatro NASCEM NULAS, e isso é a decisão que faz esta migration não mudar comportamento nenhum: `null` significa "esta instalação nunca opinou" e quem responde é o arquivo de ambiente, o PISO — mesma divisão de papéis da 0253. Um `default` já nasceria vencendo o `.env`, e a instalação que hoje declara `AI_BUDGET_ENFORCEMENT=off` passaria a bloquear gasto no primeiro deploy. As CHECKs são escritas com `is null or ...`: recusam valor que ninguém reconhece sem obrigar a instalação a ter opinião. RLS ligada com ZERO policies e o mesmo `grant` da 0253 (só `service_role`), porque a linha não pertence a organização nenhuma; nenhuma linha é semeada — quem cria é o primeiro salvamento, que é `upsert`. Baseline PG15, idempotente (`add column if not exists`). Tela em `/admin/sistema` ("Comportamento"), porta no `AdminSidebar`, `audit()` em toda escrita. Ver `lib/instalacao/comportamento.ts`. |
| `20260918014500` | `0277_pais_da_organizacao` | **O país da organização: onde moram o documento do titular, a lei citada e o prazo do direito de acesso (issue #1033, decisão do dono 25-a).** `organizations.country` (ISO-3166 alpha-2 em maiúsculas, `CHECK country is null or country ~ '^[A-Z]{2}$'`), **sem `default` e sem backfill**: `null` é Brasil, que é o comportamento de antes desta migration — nenhuma linha existente é reescrita e nenhum DEFAULT de outra coluna muda, então quem já instalou continua exatamente onde está. A localização é por ORGANIZAÇÃO, e não por instalação: duas organizações no mesmo banco podem estar em países diferentes (`APP_COUNTRY` no `.env` responderia por processo, e o processo serve todas). Nada de RLS, grant ou policy: a coluna nasce na tabela que já tem as regras dela. **O perfil do país é CÓDIGO, não configuração de instalação:** `lib/legal/perfil-do-pais.ts` (rótulo e validador do documento, lei citada, calendário de dias úteis e padrões de PII), resolvido por `perfilDaOrganizacao(supabase, orgId)` no formato de `lib/catalogo/moeda-da-org.ts` — nenhuma rota lê a coluna inline, porque duas leituras divergem no dia em que uma ganhar fallback e a outra não, e aqui a divergência prometeria a lei de um país com o prazo de outro. **A régua para um país entrar** (risco escrito na issue): o documento responde a um direito legal do titular, e citar a lei errada é pior do que não citar artigo nenhum — país entra com a citação REVISADA, ou não entra; `paisesOferecidos()` (o que o seletor mostra) exige `lei.revisada`, e o registro pode conhecer mais países do que a lista oferece. Sem citação revisada o documento não cita lei nenhuma (não há fallback para a LGPD). Sem coluna nova além desta, sem tabela nova, sem seed de feriado (o calendário vem versionado com o produto, em `lib/lgpd/holidays-br.ts`). O apêndice do baseline entra ANTES do bloco da VARREDURA anon, com `add column if not exists` + `drop constraint if exists` antes do `add constraint` (quem ATUALIZA precisa da trava de forma tanto quanto quem instala do zero). Gate: `tests/unit/pais-da-organizacao-governa-documento-e-lei.test.ts`. |
| `20260919103000` | `0322_automacao_tem_numero_proprio` | **A mensagem que a automação manda ganha número próprio no painel de atrito (#652).** O carimbo `messages.sent_via` passou a gravar `'automation'` quando quem manda é a automação (`origemDaMensagem`, em `app/api/v1/messages/_handler.ts`) — antes a pergunta era uma só (`!== "user"`) e um template fixo de regra saía `'ai'`, sem IA nenhuma no caminho. Efeito medido: a linha deixou de somar em `envios_por_ia`, e por isso `fn_atrito_metrics` ganha `por_automacao` no CTE `envios` e a chave `envios_por_automacao` em `empresa`, publicada no painel em Contenção com a nota que explica de onde ela vem (regra, texto fixo do follow-up e lembrete de agenda) e por que o número do agente cai onde há automação (`lib/metrics/atrito.ts`, `montarPares`). ADITIVO: nenhuma chave atual muda de nome; `por_ia`, `por_humano_no_sistema` e `por_humano_fora` seguem idênticos. A fórmula de "Respostas dadas pelo agente" (`taxaDeAutomacao`) NÃO mudou — incluir a automação no denominador baixaria a taxa de quem usa regra, e essa é a decisão que ficou aberta no PR, medida e declarada, não tomada em silêncio. Quem lê o número: operador de VPS com automação ligada — o "por IA" dele cai na proporção do que a regra manda, e agora existe o número que explica a queda. Teste de banco: `pnpm test:db` (invariante de atrito), prova no CI; aqui não há Docker. Corpo derivado da versão em vigor no `baseline.sql` (o MANIFEST proíbe recriar `fn_atrito_metrics` de memória: ela carrega prazos e denominadores das migrations 0133/0134/0135/0137). |
| `20260917034929` | `0268_regra_de_automacao_guarda_o_gatilho` | **A regra passa a guardar a CONFIGURAÇÃO do gatilho** (`automation_rules.trigger_config jsonb not null default '{}'`), e isso existe por causa de um gatilho que não nasce de evento: "quando faltarem N dias para uma data do funil" (issue #989). Quem o emite é a varredura `app/api/v1/cron/lead-date-field-due`, e ela só sabe onde olhar se a regra disser QUAL funil e QUAL campo de data, mais o N assinado (positivo = antes da data, negativo = depois). **jsonb, e não três colunas**: `pipeline_id`/`campo` ficariam vazios em 100% das regras dos outros nove gatilhos, e o gatilho seguinte pode precisar de outra coisa — é o mesmo desenho de `conditions` e `actions`, com o contrato de forma no TypeScript (`ConfigDoGatilhoDeData`). **Sem CHECK, de propósito**: recriar o CHECK a cada gatilho novo não é idempotente; a porta única é `createAutomationRuleSchema` (recusa a regra de data sem configuração) e a varredura pula o que chegar torto, contando em `pulados.config_invalida`, sem derrubar as outras organizações. `'{ }'` como default faz toda regra existente nascer com o objeto vazio — sem backfill e sem uma terceira forma de "sem configuração". `notify pgrst` porque a varredura SELECIONA a coluna. Gate: `tests/unit/data-do-funil-avisa-a-regra-certa.test.ts`. |
| `20260916120000` | `0266_transferencia_entre_funis_nao_e_perda` | **A transferência entre funis não é perda comercial — e a regra de automação transfere em vez de abrir um segundo negócio** (issue #992). Duas metades do MESMO nome. (1) `fn_validate_lost_reason_required` ganha o canônico `moved_to_another_pipeline`, o motivo PRÓPRIO da troca de funil: em `lib/leads/motivo-da-perda.ts` ele vira `MOTIVO_DA_TRANSFERENCIA` e passa a ser o padrão da origem encerrada (antes, `other`) — sem ele no array do trigger o encerramento volta com `22023 lost_reason_invalid`, a origem fica aberta no funil antigo e o negócio existe nos dois lugares; é o defeito que a troca existe para consertar. (2) `fn_attendant_metrics` e `fn_atrito_metrics` deixam de contá-lo: `count(*) filter (where status = 'lost' and coalesce(lost_reason, '') <> 'moved_to_another_pipeline')` — quem foi levado para outro funil não é perda de ninguém, e o contador por responsável para de engordar com movimento administrativo. **Sem coluna, sem constraint, sem backfill:** as três funções são `create or replace` puros e o estado anterior segue válido (negócio já fechado com `other` continua contando como perda — a migration não reescreve histórico); o apêndice do baseline entra antes do bloco da varredura anon. Corrige também o texto da P-01 em `docs/business-rules/00-business-rules-catalog.md`, que citava `moved_to_pipeline_X`, o motivo que o banco recusa. Catraca: `tests/unit/automacao-troca-de-funil-transfere-o-negocio.test.ts`. |
| `20260917150401` | `0273_zona_de_perigo_apaga_com_resposta_revisada` | **A Zona de perigo volta a apagar dados operacionais de organização que já enviou resposta revisada (issue #949).** `ai_reply_drafts.message_id` foi criada pela 0227 como `references public.messages(id)` SEM ação de exclusão — NO ACTION —, e era a ÚNICA das quatro FKs para `public.messages(id)` fora do padrão `on delete set null` das irmãs (`ai_agent_suggestions.message_id` na v. 11337, `messages.reply_to_message_id` na 14759, `calendar_appointments.outcome_message_id` na 19939). O apagamento da Zona de perigo (`lib/settings/apagar-dados-operacionais.ts`) começa por `messages` — é a ordem declarada em `RAIZES_DO_APAGAMENTO`, porque `messages.contact_id` é RESTRICT para `contacts` —, então o PRIMEIRO delete da organização era recusado pelo banco: medido, `SQLSTATE=23503 | update or delete on table "messages" violates foreign key constraint "ai_reply_drafts_message_id_fkey" on table "ai_reply_drafts"`. O app traduz isso no primeiro item de `ResultadoDoApagamento` com `ok: false`, `tabela: "messages"`, e **nada** é apagado: a organização fica intacta e o operador vê a ação falhar sem causa visível. **A ação escolhida é `set null`, e a evidência é o padrão das irmãs:** a irmã que passou pela mesma decisão escreveu o porquê no próprio cabeçalho — "apagar a citada não pode levar junto a resposta, que é conteúdo próprio. Perder o fio é aceitável; perder a resposta é apagar histórico por causa de um ponteiro" (14759) — e `ai_reply_drafts` guarda conteúdo próprio: `approved_body`, `edited_by`, o trace da decisão e o feedback humano. **Por que `cascade` foi preterido:** existe caminho legítimo que apaga MENSAGEM por motivo alheio à resposta — a deduplicação de eco (`delete from public.messages ... sent_via = 'external_device' ...`, v. 21770) e a exclusão de uma mensagem avulsa pela UI (`app/api/v1/messages/_handler.ts`) —, e ali cascade apagaria um rascunho revisado como efeito colateral de limpeza de ponteiro, exatamente o que a doutrina da 14759 proíbe; além disso cascade não acrescenta nada à promessa da Zona de perigo, porque quem leva os rascunhos da organização é o cascade de `conversations` (`ai_reply_drafts.conversation_id`). Estado final do caminho do defeito idêntico ao prometido — `messages` cai, `conversations` cai e leva os drafts —, sem o 23503 no meio. `message_id` é nullable (`null` sempre significou "ainda não enviado" para o filtro de leitura), então não há DEFAULT nem backfill. Nome do constraint preservado (`ai_reply_drafts_message_id_fkey`), que é o que aparece na mensagem de erro citada no suporte. **Caminhos irmãos que herdavam a mesma recusa, levantados por grep (nenhum arquivo de TypeScript foi tocado; `grep -rl 'from("messages")' app lib` cruzado com `.delete()`):** exclusão de contato (`app/api/v1/contacts/_handler.ts` apaga `messages` antes de `conversations` e `contacts` — a recusa virava o 409 enganoso de "o contato ainda tem registros vinculados") e exclusão de mensagem avulsa (`app/api/v1/messages/_handler.ts`). As rotas de admin (`admin/tenants/[id]/route.ts`, `admin/inbox/conversations/[id]/route.ts`) **não** entram: elas só contam/leem `messages`. A cascata de LGPD NÃO entra na lista: `fn_lgpd_cascade_redact_contact` anonimiza, não apaga `messages` — o único outro `delete from public.messages` em SQL é o do eco (v. 21770). Baseline INSTALL/UPDATE idempotente (`drop constraint if exists` + `add constraint`). Gate: `tests/invariants/zona-de-perigo-apaga-com-resposta-revisada.test.ts`. |
| `20260917150000` | `0274_travas_de_suporte_cobrem_toda_tabela` | **As travas do modo somente leitura do suporte cobrem toda tabela da organização já na primeira aplicação do schema**, e a instalação nova chega ao mesmo conjunto de travas que a atualização. A enumeração da 0220 que planta as restritivas `support_write_{insert,update,delete}` vira `public.fn_aplicar_travas_de_suporte()` (sem parâmetro, idempotente: `drop policy if exists` + `create policy`), chamada depois de toda tabela — na cadeia de migrations, por esta migration; no `baseline.sql`, a definição fica antes da varredura de anon e a chamada é o último bloco do arquivo (o `do` avulso do bloco da 0220 saiu, e um comentário aponta para cá). Regra de seleção inalterada: tabela comum de `public`, RLS ligada, com `organization_id` (ou `organizations`, pela `id`); gravável por `authenticated` → as três restritivas, só do servidor → nenhuma. Não é `security definer`; `revoke execute` de `public, anon, authenticated, service_role`, sem grant — só quem aplica o schema a chama. Medido em pg17 descartável com o prelúdio do `scripts/test-db.sh`, baseline aplicado UMA vez: toda tabela alcançada pela regra tem as três travas, e o conjunto de políticas é o mesmo de duas aplicações (md5 idêntico). Sem tabela, coluna ou dado novo. Invariante: `tests/invariants/travas-de-suporte-cobrem-toda-tabela-na-instalacao.test.ts`, que lê um molde de aplicação única tirado pelo `scripts/test-db.sh` entre o install e o update — e prova que ele é de uma aplicação pelo contador `test_db.aplicacoes_do_baseline`, que o script grava a cada aplicação. |
| `20260918100000` | `0279_caso_so_nasce_do_motor` | **Fecha as duas origens de escrita de `agent_cases`, `agent_case_events` e `conversation_assignment_events` para `authenticated`, e reserva `ai.case_opened`/`ai.case_closed` no `emit_event`.** As três nasceram graváveis por qualquer membro porque o `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO "authenticated"` (`baseline.sql:4727`) precede a criação delas e as policies eram `for all`/`for insert` sem papel mínimo (`tenant_isolation_agent_cases_all`, `tenant_isolation_agent_case_events_insert` e `cae_insert` — refaça a medida com `grep -nEi 'policy .*(agent_cases|agent_case_events|cae_insert)' supabase/baseline.sql`, que hoje só devolve o bloco desta migration e as duas de SELECT). **O baseline deixou de CRIAR as três para derrubá-las no fim**: criar e derrubar no mesmo arquivo faz a regra antiga valer entre os dois pontos de toda instalação, e `tests/unit/baseline-nao-constroi-o-que-derruba.test.ts` reprova. O par drop+create mora colado no bloco desta migration, e não no ponto de criação original, para que um `update.sh` sobre banco vivo não deixe a tabela sem policy nenhuma por 20 mil linhas de DDL. Um `viewer` da própria organização escrevia, falando direto com o PostgREST e com o JWT dele, o título e o resumo que a IA redigiu sobre o atendimento de uma pessoa — o texto que a equipe lê para decidir; e um INSERT forjado em `conversation_assignment_events` faz o histórico de dono dizer que alguém assumiu um atendimento que ninguém assumiu. **Nenhum caminho legítimo escreve por `authenticated`** (censo no corpo do PR): o motor usa `pg.Pool` (`lib/agent-engine/agent/human-cases.ts`), o cron usa service role (`app/api/v1/cron/case-stale-watcher/route.ts`), e a troca de dono das cinco rotas passa por `fn_conversation_assign`, que é `security definer` e insere com o privilégio do DONO — por isso claim/transfer/release seguem funcionando pelo client de sessão (controle positivo do invariante). O reset da Zona de perigo apaga `conversations` e as três somem por CASCADE, que também roda como o dono. SELECT continua aberto nas três: a tela, o MCP e o motor leem. A policy `for all` de `agent_cases` vira `for select to authenticated` — e ela precisa CONTINUAR existindo, porque `tests/invariants/rbac-config-ia-canais.test.ts` exige que toda tabela da allowlist de dívida apareça em `pg_policies`. **A reserva no `emit_event` sozinha não fecha nada** — ela só barra quem tem `auth.uid()`; quem fecha a forja da linha é o revoke, e por isso as duas andam na mesma migration. O corpo do `emit_event` é o VIGENTE derivado (a definição de maior número de linha no baseline, conferida idêntica à última da cadeia, em `0224_presenca_e_recuperacao`), com a única mudança sendo a lista de tipos reservados; a mensagem fica com o nome herdado (`reserved_message_received`) porque renomeá-la é mudança de contrato observável. Fecha com `fn_aplicar_travas_de_suporte()` (0274), que dá ZERO policies `support_write_*` a tabela server-only — o contrato mais restritivo. Número `0279` e não `0277`/`0278`: os dois estão tomados por branches locais de triagem (`triagem/714-smtp-como-opcao` e `triagem/recorte-928`) — medido sobre todas as refs, e o hook `check-migration-triple.sh` reprovou o `0278` no ato do commit. Sem tabela, coluna ou dado novo. Gate: `tests/invariants/caso-so-nasce-do-motor.test.ts`. |
| `20260918101000` | `0280_anonimizar_alcanca_o_caso` | **A cascata de LGPD passa a alcançar o que a IA escreveu SOBRE a pessoa quando o atendimento travou.** `fn_lgpd_cascade_redact_contact` percorre uma lista escrita à mão e quatro tabelas nunca entraram nela: `agent_cases` (`title` vira o rótulo, `summary`/`blocker` viram texto fixo, `context_snapshot` vira `{}`), `agent_case_events` (`body` nulo, `metadata` `{}`), `demandas` (`assunto` nulo) e `agent_inbox_items` (resolve, corpo fixo `Contato anonimizado.` e `ref_id` nulo). O modo de falha era mudo: a rota devolvia SUCESSO, a contagem por tabela fechava, o SLA de D+15 era marcado como cumprido e o relato continuava legível — com o nome de quem pediu para ser esquecido dentro do aviso que a Central mostra. **`agent_cases.updated_at` fica FORA do `set`, de propósito:** o cobrador de caso parado (`app/api/v1/cron/case-stale-watcher/route.ts`) o lê como "alguém da equipe encostou neste caso", e escrever ali faria a anonimização ADIAR a cobrança de um caso que continua parado. **O vínculo de `agent_inbox_items` tem TRÊS braços, medidos nos produtores:** `handoff` nasce com `ref_kind='contact'` (`lib/ai/handoff/orchestrator.ts`) E com `ref_kind='conversation'` (`lib/agent-engine/agent/inbound-turn.ts`); `case_stale` nasce SEMPRE com `ref_kind='agent_case'` (a rota do cron acima, e a política em `lib/ai/inbox-destino.ts`) — um predicado com os dois primeiros braços casaria ZERO avisos de caso parado, e casar zero linha não é erro: é sucesso com o texto intacto. Os `kind` são os do CHECK vigente (`case_opened` não existe; reconfira com `grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql`). Cada passo PRESERVA o que é operação — estado, dono, marcas de tempo, contagem: um passo que apagasse a linha inteira ficaria verde num teste de "o texto sumiu" e tiraria da organização a resposta a "quantos atendimentos pararam em março". O corpo é o VIGENTE derivado (a definição de maior número de linha no baseline, copiada por script — provado: removendo cabeçalho, passos novos e o `notify`, o texto restante é byte a byte o de antes). `lib/lgpd/export-collector.ts` ganha os três blocos correspondentes (`cases`, `case_events`, `demandas`), porque `tests/unit/lgpd-exporta-o-que-redige.test.ts` deriva as duas pontas da fonte e reprova sozinho sem eles — o que se apaga a pedido do titular é o que se entrega a pedido dele. Sem tabela, coluna ou dado novo; `lib/database.types.ts` não muda. Gates: `tests/invariants/lgpd-caso-anonimiza.test.ts` (o efeito) e `tests/invariants/cascata-lgpd-nao-encolhe.test.ts` (a catraca da lista nomeada, nos DOIS sentidos — a função é reescrita por cinco entregas e o Postgres troca o corpo INTEIRO). Número `0280` e não `0278`: `0277` e `0278` estão tomados por branches de triagem e `0279` é a migration da onda anterior — medido sobre todas as refs. |
| `20260918102000` | `0281_conversa_do_caso` | **Onde a consulta da equipe à IA sobre um caso fica guardada, e por que não cabia em lugar nenhum que já existia.** Cria `agent_case_chat_messages` (uma linha por mensagem, `turn_id` agrupando pergunta e resposta) porque as três alternativas óbvias quebram consumidores vivos: `conversation_notes` é lida por `lerContinuidadeHumana` e viraria instrução literal no prompt do agente que fala com o CLIENTE; `agent_case_events` com `actor_kind='human'` infla `intervencoes` e zera `espera_fila` em `fn_atrito_metrics` — o Índice de Atrito passaria a medir LEITURA em vez de DECISÃO; e qualquer escrita em `agent_cases` mexeria em `updated_at`, que é o sinal de "alguém encostou" do `case-stale-watcher`. A coluna de texto chama-se `body` **de propósito**, e há `contact_id` com FK: são as DUAS condições que `tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts` exige para enxergar a tabela — com `pergunta`/`resposta` ela nasceria invisível ao gate. A redação entra **dentro** de `fn_lgpd_cascade_redact_contact` (trigger novo com outro nome não conta para aquele instrumento), derivada por script do corpo vigente; o export ganha o bloco pareado (`tests/unit/lgpd-exporta-o-que-redige.test.ts` reprova quem redige e não exporta); e `fn_expurgar_conversa_do_caso_vencida` (365 dias, piso de 90 **no corpo**) entra no cron diário de retenção. RLS `for select` com TRÊS condições — organização, `fn_role_at_least('agent')` e `fn_can_view_conversation` —, `revoke all from anon, authenticated` **antes** do `grant select` (o `ALTER DEFAULT PRIVILEGES` do baseline precede toda tabela de apêndice) e **zero** caminho de escrita pelo PostgREST: quem escreve é o servidor. A idempotência do POST é a unique `(organization_id, case_id, turn_id, author_kind)` e não `idempotency_keys` — aquele helper grava `response_body`, que seria uma cópia da resposta sobre a pessoa numa tabela fora da cascata e sem expurgo (`grep -rn "idempotency_keys" app/api/v1/cron lib/retencao lib/lgpd` → vazio; rode para conferir). Para ver a lista de tabelas que a cascata alcança hoje, sem acreditar nesta linha: `psql -c "select distinct m[1] from pg_proc p, lateral regexp_matches(pg_get_functiondef(p.oid), '(?:update\|delete from)\s+(?:public\.)?\"?([a-z_]+)\"?','gi') m where p.proname='fn_lgpd_cascade_redact_contact' order by 1"`. |
| `20260918110000` | `0291_passagem_para_humano_tem_registro` | **Toda passagem do atendimento automático para uma pessoa vira uma linha de fato.** Cria `passagens_de_atendimento` — uma linha por episódio, com o motivo (vocabulário fechado, traduzido na tela por `lib/escalacao/passagem.ts` → `FRASE_DO_MOTIVO`), o que a IA já tentou (`tentativas` jsonb, validado por Zod ANTES do insert), o que ela entendeu que o cliente quer (`title`), a narrativa que quem assume lê (`body`), as PALAVRAS LITERAIS do cliente (`notes`), o texto livre de quem passou (`content`), a VERDADE sobre o aviso ao cliente (`cliente_avisado` + `aviso_motivo_codigo`, também fechado) e o par de reconhecimento. Hoje nada disso sobrevive ao turno: o motor A monta um resumo que morre no corpo do aviso da Central e o motor B abre o aviso SEM resumo nenhum — quem assume relê a conversa inteira e o cliente repete o que já disse. **As quatro colunas de texto se chamam `title`/`body`/`notes`/`content` DE PROPÓSITO**, e há `contact_id` com FK: são as duas condições que `tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts` exige para enxergar a tabela — com `resumo`/`motivo_texto`/`cliente_quer` (o desenho anterior) ela nasceria INVISÍVEL ao gate e a cobertura dependeria de um invariante comportamental que uma sessão futura pode apagar com o gate de classe verde. `caso_id` é `on delete set null` e não `cascade`: apagar o caso não pode apagar o fato de a conversa ter ido para uma pessoa. RLS `for select` com TRÊS condições — organização, `fn_role_at_least('agent')` e `fn_can_view_conversation` (é por isso que `conversation_id` mora dentro) —, `revoke all from anon, authenticated` **antes** do `grant select`, e ZERO caminho de escrita pelo PostgREST: uma passagem forjada por um membro diria que a IA desistiu de um atendimento que ela nunca tocou. Os CHECK são criados inline E redeclarados por `drop constraint if exists` + `add` com o MESMO nome que o Postgres dá ao inline — auto-cura sem duplicar, porque duas constraints definindo o mesmo vocabulário fazem `vocabulario-banco-x-typescript.test.ts` se RECUSAR a medir. `fn_expurgar_passagens_vencidas` (1825 dias, piso de 90 **no corpo**) entra no cron diário de retenção como sexta poda e **só apaga passagem JÁ RECONHECIDA**: passagem aberta é alguém esperando resposta, e apagá-la por idade seria o expurgo virando esquecedor de pendência. A cascata de LGPD ganha o passo da tabela (`body` é `not null` e recebe o RÓTULO, como `voice_calls.peer_phone`) e `conversations.last_handoff_reason = null` dentro do passo 2 que já visita as mesmas linhas — o corpo é o VIGENTE derivado por script, com as duas edições provadas reversíveis byte a byte. `lib/lgpd/export-collector.ts` ganha o bloco pareado, porque `tests/unit/lgpd-exporta-o-que-redige.test.ts` reprova quem redige e não exporta. **Ninguém escreve nesta tabela ainda**: os 13 call sites dos dois motores, o reconhecimento automático por `fn_conversation_assign` e o cartão na conversa são das ondas seguintes. Gates: `tests/invariants/passagem-isolamento-e-visibilidade.test.ts`, `lgpd-cascata-alcanca-a-passagem.test.ts`, `passagem-retencao.test.ts`, e as três listas nomeadas (`rls-isolation` → `TABLES`, `vocabulario-banco-x-typescript` → quatro pares, `cascata-lgpd-nao-encolhe` → a catraca). Número `0291` e não `0281`: as ondas anteriores consumiram `0279`–`0281` — medido sobre todas as refs com `git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*'`. |
| `20260918121000` | `0292_aviso_de_caso_no_whatsapp` | **O WhatsApp da equipe passa a ser avisado quando a IA abre um caso — e o aviso tem registro de entrega.** Cria `config_aviso_de_caso` (UMA linha por organização: para onde mandar, por qual conexão, ligado ou não, mais os dois contadores do descarte) e `entregas_de_aviso_de_caso` (UMA linha por organização+caso+destino). É a unique dessa segunda que dá a IDEMPOTÊNCIA: o dreno do `event_log` reentrega o mesmo evento em retry e há três drenos em processos diferentes — sem ela a equipe receberia o mesmo aviso uma vez por tentativa. **O texto do aviso NUNCA é guardado** (só `corpo_hash`, precedente `send_ledger.body_hash`): guardá-lo seria uma segunda cópia do relato do cliente numa tabela que a cascata teria de aprender a redigir. A única coluna capaz de ecoar um dado pessoal é `erro_detalhe` (o texto cru do transporte), e é ela que a cascata zera. **NÃO existe `check (ligado = false or channel_session_id is not null)`**, e a ausência é deliberada: `on delete set null` é um UPDATE, o CHECK seria reavaliado e VIOLARIA com `ligado=true`, abortando o DELETE INTEIRO da conexão — a rota de exclusão de canal devolveria 500 com mensagem de constraint, sem pista de que a causa está em outra tela. A coerência é do trigger `trg_aviso_de_caso_coerente`, que se autocura (canal nulo ⇒ `ligado` cai para `false`). Escrita da configuração só por `fn_definir_aviso_de_caso` (`security definer`, quatro guardas na mesma transação: `admin`, escrita de suporte liberada, MFA comprovada quando há fator, e o canal sendo da própria organização), que ainda recusa o número da PRÓPRIA organização (o laço robô-com-robô) e, sem `p_confirma_contato`, o número que já é de um CLIENTE — porque configurá-lo faz as mensagens daquela pessoa pararem de chegar ao CRM. O casamento usa as DUAS grafias do nono dígito, a mesma regra de `lib/channels/phone-variants.ts`. Mais duas funções só para o servidor: `fn_registrar_jid_do_aviso` (o handler grava o JID que o transporte resolveu — é o que faz o corte da ingestão valer para destinatário em modo privacidade) e `fn_contar_mensagem_ignorada` (os contadores do descarte). Nenhuma das duas toca `updated_at`, que responde 'alguém MEXEU na configuração': uma resposta do suporte não é alguém mexendo na configuração. Leitura por RLS `for select`: configuração é `admin` (quem configura conexão é admin), histórico é `manager` ('o aviso está saindo?' é pergunta de quem opera). Nenhuma policy `for all` ⇒ as duas nascem fora da consulta `cmd='ALL'` do gate de RBAC. `fn_expurgar_avisos_de_caso_vencidos` (180 dias, piso de 30 **no corpo**) entra no cron diário de retenção como sétima poda. Dois vocabulários crescem: `agent_case_events.kind` += `alert_sent` e `agent_inbox_items.kind` += `aviso_de_caso_nao_entregue`. Na CADEIA as duas constraints são reconstruídas com a lista INTEIRA (`tests/unit/kind-check-migration-x-baseline.test.ts` exige igualdade valor a valor com o baseline); no `baseline.sql` o valor entra no bloco ÚNICO já existente, porque um segundo bloco é o defeito da issue #159. A cascata de LGPD ganha o passo de `entregas_de_aviso_de_caso` (`erro_detalhe = null`) e o kind novo dentro do `in (...)` do passo de `agent_inbox_items` — corpo VIGENTE derivado por script, com as duas edições provadas reversíveis byte a byte. **Ponto cego declarado:** `tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts` só cobra tabela com FK para `contacts` E coluna de nome-de-PII; nenhuma das duas satisfaz, então o gate ficaria VERDE sem aquele passo. Ele entra porque é certo, e quem o vigia é a catraca `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. Para ver o vocabulário em vigor sem acreditar nesta linha: `grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql`. Número `0292` e não o `0280` do plano: `0279`–`0282`, `0290` e `0291` estão tomados — medido sobre todas as refs com `git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*' | grep -oE '_0[0-9]{3}_' | tr -d _ | sort -u | tail`. |
| `20260918130000` | `0293_a_passagem_se_reconhece_sozinha` | **O aviso de passagem para humano se resolve sozinho quando alguém assume — e a passagem fica marcada como reconhecida.** A `0291` criou `passagens_de_atendimento.reconhecido_por/_em` e NINGUÉM os escrevia; sem escritor, o cartão da conversa nunca sai de "esperando alguém assumir", o aviso da Central fica aberto para sempre e — o pior — como o aviso deduplica por episódio ABERTO, a PRÓXIMA passagem daquela conversa não abre aviso nenhum: o cliente pede um atendente de novo e ninguém é avisado. Além disso `fn_expurgar_passagens_vencidas` só apaga linha reconhecida (é o certo: passagem aberta é demanda viva), então a tabela nunca seria podada. Cria `fn_passagem_reconhecida()` + `trg_passagem_reconhecida` em `conversation_assignment_events`: **UM gatilho cobre os cinco caminhos** que trocam o dono de uma conversa (assumir, transferir, liberar, devolver, rodízio), porque todos passam por `fn_conversation_assign`, que insere a linha de auditoria na MESMA transação — e cobre o sexto que alguém escrever amanhã, sem tocar em rota nenhuma. SQL puro: **nenhum HTTP dentro de trigger** (anti-pattern nº 9). Ele traz uma SEGUNDA camada de guarda além do `revoke insert` que a `0279` já pôs na tabela de eventos: só reconhece quando `conversations.assigned_to_user_id is not distinct from new.to_user_id`, ou seja, quando a conversa REALMENTE está com aquele dono — uma linha de auditoria incoerente escrita com a service key marcaria como assumida uma passagem que ninguém assumiu, e o aviso sumiria da lista de quem precisa agir. `to_user_id is null` (release/devolução) NÃO marca: quem fecha esse episódio é `fn_passagem_devolvida(uuid,uuid)`, também criada aqui, que grava `reconhecido_em` SEM `reconhecido_por` — o par que a `0291` documentou como "devolvida ao automático: ninguém assumiu, mas o episódio fechou" e que o CHECK `passagens_reconhecimento_coerente` permite só nesse sentido. Ela é `security definer` porque a policy da tabela é `for select` apenas (`authenticated` não tem `update`, de propósito: ninguém reescreve um fato) e porque abrir o client de serviço dentro de uma rota quando há molde de definer no repositório é privilégio a mais sem necessidade — a autorização mora NO CORPO, no padrão de `fn_conversation_assign` (`auth.uid() is not null and not fn_role_at_least(org,'agent')` ⇒ `raise`). As duas funções revogam de `public, anon` (as DUAS origens de EXECUTE) e a devolvida concede a `authenticated, service_role`. Chamada por `lib/escalacao/retomada.ts`, que serve a rota "devolver ao automático" E a tool MCP — um lugar só. Gate: `tests/invariants/passagem-se-reconhece-sozinha.test.ts`. Número `0293` e não o `0282` do plano: `0279`–`0283` e `0290`–`0292` estão tomados — medido sobre todas as refs com `git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*' | grep -oE '_0[0-9]{3}_' | tr -d _ | sort -u | tail`. |
| `20260918140000` | `0294_o_cliente_repetiu_depois_da_passagem` | **O laço de retorno da passagem vira número, e o cobrador ganha onde contar.** A entrega de 0291/0293 pôs o contexto da passagem na frente de quem assume; faltava a medida que diz se aquilo serviu: *depois de a IA passar a conversa, o cliente precisou repetir o que já tinha dito?* `fn_atrito_metrics` ganha DUAS chaves em `cliente` — `repeticao_pos_passagem` (numerador: passagens em que o cliente repetiu) e `passagens_medidas` (denominador: passagens em que ele VOLTOU A FALAR). A razão NÃO é calculada no SQL: ela é montada em `lib/metrics/atrito.ts`, que é onde mora a regra *ausência de dado é `null`, nunca `0`* — publicar os dois números separados é o que permite distinguir "ninguém repetiu" de "ninguém voltou a falar", e o que torna a régua auditável por quem lê a tela. **A régua é declarada e fixa**: limiar `p_repeticao_min` (0.7, o MESMO do índice de repergunta — dois limiares para o mesmo fenômeno dariam dois números incomparáveis na mesma tela) e janela de 24 h depois da passagem. O instrumento já existia (`fn_atrito_jaccard`, 0135, `immutable`); nenhuma tabela de agregado nasce, porque número sincronizado por cron onde cabe uma consulta é o anti-pattern nº 5. ⚠️ `fn_atrito_metrics` é **SECURITY INVOKER**: um `agent` em `visibility_mode='own'` vê o número só das conversas dele, `manager`/`admin` veem o da organização — a ressalva viaja na `nota` da medida, junto do número. A segunda coisa é `passagens_de_atendimento.cobrancas int not null default 0`: o reconhecimento de 0293 só acontece por gesto de quem CHEGOU, e ninguém cobrava a passagem em que ninguém chegou (dos treze caminhos, UM nasce de caso — o `case-stale-watcher` não alcançava os outros doze nem por acidente; a população foi medida num CRM em produção com o mesmo desenho de fila: 22 pedidos parados, o mais antigo há 17,6 dias, onze deles gente pedindo para falar com uma pessoa). O segundo braço do MESMO cron reabre/eleva o aviso `handoff` da conversa e para no terceiro aviso, e é `cobrancas` que segura esse teto — sem contador o alarme nunca cala, e alarme que nunca cala treina a equipe a ignorar o alarme certo. Sem tabela nova, sem `kind` novo, sem evento novo. Corpo de `fn_atrito_metrics` DERIVADO do vigente por script, com as duas edições provadas reversíveis byte a byte. Gates: `tests/invariants/atrito-repeticao-pos-passagem.test.ts`, `tests/unit/cobrador-de-passagem-nao-reconhecida.test.ts`, `tests/unit/atrito-par-eficiencia-dano.test.ts`. Número `0294` e não o `0282` do plano: `0279`–`0283` e `0290`–`0293` estão tomados — medido sobre todas as refs com `git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*' | grep -oE '_0[0-9]{3}_' | tr -d _ | sort -u | tail`. |
| `20260919154000` | `0333_transporte_smtp_da_instalacao` | **O SMTP entra como OPÇÃO de e-mail, e a Resend continua funcionando (PR #714, @betoarts, recorte).** `platform_smtp_settings`: singleton de escopo de INSTALAÇÃO (host, porta, política TLS/STARTTLS, usuário, senha cifrada, remetente e nome), no mesmo desenho de `platform_meta_app` (0257) e `platform_google_oauth` (0201) — RLS ligada SEM policies, `revoke all … from anon, authenticated` (obrigatório: o `alter default privileges` do topo do baseline concede tabela nova aos dois) e `grant select, insert, update` só ao `service_role`, atrás do gate de `/admin/email`. Senha cifrada por `fn_encrypt_oauth`; **nunca volta ao browser** — a tela recebe só um booleano dizendo se existe. O que ESTA migration não faz: não derruba nada da Resend. O PR original secava o caminho antigo (o `lib/email/resend.ts` virava um shim de 5 linhas, o pacote saía do `package.json`, as chaves saíam do Zod e uma migration irmã dava `drop table` na configuração anterior); o dono do produto decidiu o contrário — os dois caminhos convivem e quem tem Resend não mexe em nada, então esse secamento ficou fora do recorte e nenhuma tabela é removida aqui. Quem escolhe o transporte em runtime é `lib/email/roteador.ts` (SMTP quando há SMTP configurado; Resend quando não há), vigiado por `tests/unit/email-marca-e-remetente.test.ts`. As sete `SMTP_*` de `lib/env.ts` seguem valendo como piso de rollback e como caminho de provisionar a VPS sem abrir interface; o BANCO prevalece (`lib/email/config.ts`). Sem coluna nova em tabela existente, sem backfill, sem dado tocado. Apêndice do baseline idempotente (INSTALL e UPDATE). |
| `20260918011500` | `0282_extensoes_perfil_v2` | **O banco passa a conhecer o perfil declarativo v2 (ADR-0003).** A validação do CATÁLOGO em `fn_extensions_admit_catalog` fixava `permissions` em `["navigation.tasks"]` (0271:194) e recusava chave desconhecida na entrada (0271:184): sem esta migration, um catálogo v2 é recusado com `extension_invalid_input` e o contrato novo viveria só no TypeScript. Permissões passam por `public.fn_extensions_permissoes_validas` (conjunto fechado, não vazio, sem repetição — espelho de `EXTENSION_PERMISSIONS` em `lib/extensions/capacidades.ts`), e o catálogo aceita o metadado de loja (`publisher_label`, `homepage`, `repository`, `tags`, `published_at`), que **não** entra no manifesto: o pacote descreve o que faz, o catálogo revisado descreve de quem é. Acrescenta `extension_permissions_changed`, a recusa que a spec v1 prometia "quando o contrato admitir outra permissão" — sem ela, atualizar de 1.0 para 1.1 acrescentaria uma porta sem ninguém na organização rever a lista que a tela existe para mostrar; vale em `fn_extensions_finish_install` (kind `update`) e em `fn_extensions_revert_install`. As três funções são reescritas INTEIRAS com o corpo da 0271 preservado, e não por substituição de texto sobre `prosrc` em tempo de execução, que dependeria do estado de cada clone. Sem tabela, coluna ou dado novo. Apêndice idempotente derivado do próprio arquivo da migration. |
| `20260918230000` | `0311_webhook_do_numero_no_canal_oficial` | **Três colunas de DESFECHO do registro automático do webhook do canal oficial** (`channel_sessions.meta_webhook_override_uri` / `_erro` / `_em`): a URL que ficou registrada na Meta, o motivo da última falha e quando foi a tentativa. Fecha o defeito silencioso da fatia F1 da #850 — conectar o canal oficial deixava o operador com um canal que ENVIA e não RECEBE até ele colar a URL de callback à mão no painel da Meta, e por número; quem não sabia disso não via erro em lugar nenhum. Por COLUNAS, e não no `metadata` jsonb da sessão: são três leitores (GET do canal, POST de conexão, rota de re-registro) e chave dentro de jsonb é contrato que o `update` do ingest apaga sem avisar. `add column if not exists`, sem índice (lida sempre pela chave primária), sem grant novo: a URL carrega o `webhook_path_token`, que já vive nesta tabela sob RLS por organização. A limpeza no arquivamento e a reaplicação na reconexão ficam para a F1b, em cima destas mesmas colunas. Baseline INSTALL/UPDATE idempotente. |
| `20260918220000` | `0308_espera_longa_dorme` | **A espera longa de um fluxo de follow-up passa a sobreviver ao contato mandar mensagem**, que é o que faltava para uma cadência de retorno ("volte a falar daqui a 28 dias") caber num fluxo em vez de morar no prompt do agente. Duas coisas a matavam, as duas caladas: `lib/followup/reactivity.ts` ou CANCELA a inscrição parada num `wait` (`cancel_on_reply`) ou grava `inbound_woke` e **corta o timer** — e numa espera de 28 dias a cliente de manutenção fala com o estúdio várias vezes, sem que isso signifique antecipar nem desistir do retorno; e o índice único anti-spam trancaria o contato fora de qualquer outra cadência por um mês. Por isso a regra vivia no prompt chamando `crm_schedule_followup` (que grava em `cron_jobs`, é imune e não ocupa vaga), onde não dá para editar o prazo, ver quem está esperando nem medir o resultado. O status novo `dormente` é a **projeção** em runtime de `wait.immune_to_reply` (campo novo do nó, só no modo `fixed`, exigido ≥24h por `immune_wait_too_short` em `validate-publish.ts`) — não uma coluna `imune`, que seria segunda verdade sobre o mesmo fato e divergiria no primeiro republish; quem a escreve é o mesmo `wake_status` que já projeta `waiting_reply` (`node-handlers.ts` → `engine.ts`). É o status que faz a feature custar **zero** na reatividade: `LIVE_STATUSES` não o inclui, então a inscrição dormente nem é carregada — nenhuma query nova por mensagem recebida, nenhum grafo lido ali — com uma exceção deliberada, o ramo de STOP/opt-out, que alcança o dormente como alcança qualquer outro (LGPD não admite exceção de status). **`idx_followup_enrollments_one_live` NÃO é tocado**: ele enumera os status que ocupam vaga e `dormente` fica de fora por construção, então a vaga é liberada sem uma linha de DDL sobre o índice e o guard anti-empilhamento continua com a força de antes para os status que já cobria. `dormente` entra na perna **com relógio** do CHECK de coerência (é o `next_eval_at` que o acorda, pelo mesmo claim — não há segundo agendador), e nas duas listas de `fn_claim_due_followup_enrollments` mais no índice parcial do claim: **é aí que esta migration falha calada se alguém a encurtar** — sem isso a inscrição dorme e nunca acorda, sem nada reprovando. Os dois CHECKs saem pelo catálogo (`pg_constraint`), não pelo nome, porque em clone que passou por dump/restore o nome gerado diverge e o drop por nome fixo falharia em silêncio (mesmo cuidado da 0145). Re-aplicável: os CHECKs só ampliam o conjunto aceito, o predicado novo do índice cobre todas as linhas do antigo, e nenhum banco tem `dormente` antes desta migration — sem backfill e sem dado tocado. |
| `20260918001726` | `0276_rodada_do_banco` | **A rodada de atualização carrega o que aconteceu com o banco — disputa, retentativas e passada — e a tela de atualização conta isso quando termina.** Três colunas em `system_update_runs` (`disputa_de_banco`, `retentativas_do_banco`, `passada_do_banco`) guardam, por rodada, se houve disputa de lock com o sistema no ar, quantas retentativas o kit gastou e em qual passada o baseline reaplicado fechou — a mecânica que o PR #997 mostrou (o baseline reaplicado sobrevive à disputa) e que até aqui só existia no log do servidor. Nulo é "não medido" (caminho que não passou pelo banco, como atualização só de código) e a tela não inventa texto para isso. A CHECK `system_update_runs_rodada_do_banco_coerente` recusa número incoerente — mais retentativas do que passadas. |

| `20260919153000` | `0325_protecao_de_tabela_de_organizacao_sai_do_laco` | **As proteções de tabela de organização saem do laço do baseline para funções SEM PARÂMETRO — o mecanismo que a ADR-0002 (D5) exige para que tabela criada DEPOIS do baseline nasça protegida.** Irmã da 0274: lá foram as três restritivas do suporte, aqui são RLS ligada, `revoke all … from anon` e o isolamento por organização, em `public.fn_proteger_tabelas_de_organizacao()`, mais o ponto de entrada `public.fn_proteger_modulo_provisionado()` que uma provisionadora de módulo chama no fim do corpo (ela encadeia a rotina nova e depois a da 0274 — a ORDEM importa, porque as travas do suporte leem o privilégio de `authenticated` de cada tabela para decidir entre as três restritivas e o contrato server-only). **A régua é `not relrowsecurity`, e não "toda tabela com organization_id" — medido, não suposto.** Num pg17 descartável com o baseline de `ed42ad119` aplicado com `ON_ERROR_STOP=1`, das **119** tabelas de organização: **0** sem RLS, **49** ainda com privilégio de `anon`, **8** server-only (RLS ligada e ZERO policies, de propósito: `platform_support_sessions`, `extension_operations`, `channel_connection_requests`, `appointment_recovery_receipts`, `event_service_origins`, `ad_platform_connections`, `ad_insights_connections`, `ad_conversion_dispatches`) e **66** sem a policy ampla. Varrer as 119 abriria as 8 a todo membro da organização e atropelaria as policies por papel das 66 — o próprio baseline avisa disso na linha de `ai_routers`. Já uma tabela recém-criada (o que a provisionadora produz) nasce com `relrowsecurity=false`, `anon` com SELECT e zero policies; nenhuma das 119 está nesse estado. Logo a varredura é **no-op no baseline de hoje** e **exata** para a tabela de módulo de amanhã — e um módulo que queira policy por papel ou server-only liga a RLS ele mesmo, e sai do alcance dela. **Prova de que o comportamento do baseline não mudou:** impressão digital do catálogo (RLS por tabela, as 491 policies com papéis/`using`/`with check`, ACL de tabela, ACL de função, colunas, índices, gatilhos) tirada antes e depois num pg17 descartável — **3522 → 3524 linhas, e as 2 de diferença são as duas funções novas**; `install(novo) == update(novo) == clone-do-baseline-antigo-atualizado`, os três com diff vazio. **As listas enumeradas do baseline continuam onde estão**, de propósito: trocá-las pela varredura no MEIO do arquivo mudaria o resultado, porque a medição de "0 sem RLS" é do estado FINAL. Nenhuma das duas é `security definer`; `revoke execute` das duas origens (`public, anon, authenticated, service_role`), sem grant — só quem aplica o schema as chama, e a provisionadora `security definer` do módulo roda como o dono. Sem tabela, coluna ou dado novo. Invariante: `tests/invariants/provisionadora-de-modulo.test.ts` (as três regras de forma da D4, com controle positivo por provisionadora de mentira, já que o conjunto real ainda é vazio) e o molde `tests/invariants/molde-de-provisionadora.ts`, que um módulo novo herda com uma chamada. |
| `20260919120000` | `0321_recibo_de_idempotencia_em_curso` | **O recibo de idempotência ganha o estado "em curso" — e a corrida de duas requisições SIMULTÂNEAS com a mesma `Idempotency-Key` deixa de produzir o efeito duas vezes (issue #778).** `public.idempotency_keys.status_code` e `response_body` deixam de ser `not null`: a linha passa a ter dois estados, e o `check` `idempotency_keys_recibo_ou_reserva` (`(status_code is null) = (response_body is null)`) diz qual é qual. **RESERVA** — os dois nulos, gravada ANTES do efeito por quem ganhou o índice único: é ela que faz o segundo pedido colidir com `23505` em vez de reexecutar, e `expires_at` curto enquanto reserva garante que processo que morreu no meio não tranca a chave (reserva vencida é de quem chegar depois; efeito que lança erro expira a própria reserva, e a retentativa executa). **RECIBO** — os dois preenchidos, estado terminal de 24h que já existia, e é ele que responde "esta chave já produziu efeito?" no replay. O `check` fecha o meio-termo (um gravado e o outro não), que nenhum leitor sabe interpretar e que só apareceria por bug de escrita. **A escolha entre fechar no schema e fechar por RPC em cada rota está medida no PR da issue:** o caminho reutilizável é o de efeito em CÓDIGO de aplicação, e código não cabe na transação do recibo — lá a única forma de serializar é a linha existir antes. **Para quem já instalou, nada muda:** toda linha existente tem as duas colunas preenchidas e passa no `check` sem backfill, a nulidade sai e o `check` entra na MESMA migration (entre uma e outra o banco teria um estado que o produto não usa), e RLS, policies e grants da tabela não são tocados — a travinha `idempotency_platform_creation_server_only` segue recusando o caminho privilegiado a `anon`/`authenticated`. **Derivada no `baseline.sql` em dois lugares, e os dois são necessários:** o `create table` do corpo já nasce anulável (install), e o bloco `(migration 0321)` do apêndice faz os dois `alter column ... drop not null` e a constraint — sem ele o `update.sh` de uma VPS já instalada passa pelo `create table if not exists` sem efeito, as colunas seguem `not null` e toda reserva falha com `23502` (medido em Postgres 17 na triagem do PR #1189). Gate da simultaneidade: `lib/api/idempotency.test.ts` e `tests/invariants/idempotencia-reserva-antes-do-efeito.test.ts`. **Número:** já foi `0278` (colidiu em timestamp com a 0310) e depois `0314` (colidiu em NNNN com o PR #1123) — renumerada duas vezes em 18/09, sem mudar o corpo. |
| `20260918210000` | `0306_google_ads_captura_de_clique` | `google_ads_landing_pages` (WhatsApp + template por organização) e `google_ads_click_refs` (par token curto ↔ gclid, criado no clique e consumido quando a mensagem chega). Fecha a lacuna que `lib/plataformas-de-anuncio/registry.ts` (0213) já declarava: sem landing page não havia extrator de `gclid` para o Google Ads. Server-side only, mesmo desenho de `ad_platform_connections` — RLS ligada sem policies, grants de anon/authenticated revogados. Sem função nova em `public`. Recorte da frente de Google Ads do PR #965 (@automatikpg-ux), cuja frente de Instagram sai em PR próprio por decisão do dono do produto; renumerada de 0263 para 0306 porque 0263 já está ocupada na `main` (a numeração original do contribuidor era 0252, renumerada por ele para 0263 ao atualizar a branch). |
| `20260918211000` | `0307_google_ads_conversao` | `ad_platform_connections` ganha `google_refresh_token_encrypted`, `google_customer_id`, `google_login_customer_id`, `google_conversion_action_id`. Fecha o transporte de conversão do Google Ads (`lib/plataformas-de-anuncio/google/conversions.ts`): refresh token OAuth em vez de access token longo-vivo (o da Meta dura meses, o do Google ~1h), mais os três identificadores de para onde reportar. Colunas nullable na MESMA tabela, sem CHECK cruzando `platform` — a completude por plataforma é de `lib/plataformas-de-anuncio/credenciais.ts`, não do schema. Recorte do PR #965; renumerada de 0265 para 0307 (0265 é buraco na `main`, e a doutrina é max+1, nunca preencher buraco). |
| `20260918231000` | `0312_aviso_de_followup_sem_agente` | **O fluxo de follow-up publicado que nunca vai disparar passa a aparecer na Central.** Gatilho automático (`silence`, `stage_change`, `case_opened`, `appointment_no_show`) só cria inscrição se algum agente PUBLICADO tem o ponteiro em `followup.flow_pointer_ids` — regra em `lib/followup/agent-followup-gate.ts`, repetida dentro de `fn_appointment_recover` para a recuperação de falta. Sem esse vínculo os produtores saem por `pointers_armados = 0` **em silêncio**: o fluxo fica `active` na tela, com versão publicada, e nenhum contato é reengajado — sem erro, sem log que alguém leia, sem linha em tabela nenhuma. É o invariante 6 do Sistema Vivo (o `return` mudo em cima de estado configurável), e ficou mais fácil de cair com a galeria de modelos (`lib/followup/modelos/`), que deixa instalar um fluxo em dois cliques sem passar pelo agente. Acrescenta o kind `followup_sem_agente` ao CHECK de `agent_inbox_items` (recriado INTEIRO, como a 0233 e a 0242 — `add constraint` não é idempotente) e o índice parcial `agent_inbox_items_followup_sem_agente_aberto_idx (organization_id, ref_id) where kind='followup_sem_agente' and status='open'`, que o cron `followup-sem-agente` consulta duas vezes por rodada: para não abrir o segundo aviso e para FECHAR o aberto quando o vínculo aparece. `ref_kind='followup_flow'`, `ref_id` = o id do ponteiro. Sem coluna nova, sem backfill: nenhuma linha existente usa o valor novo. |
| `20260919130000` | `0323_marcadores_do_contato_na_conversa` | **O filtro por marcador do Inbox casa a caixa da conversa OU a do contato.** Campo calculado `public.tags_do_contato(conversations)` (SECURITY INVOKER, `stable`, revogado de public/anon) para o `or=(tags.cs.{x},tags_do_contato.cs.{x})` do handler da lista; dispensa a lista de `contact_id` na URL, que tinha teto de ~186 ids. PR #1206 (@rafaelbatistazz). Apêndice no baseline antes da varredura de anon. |
| `20260919220000` | `0339_canal_mudo_sem_numero` | **O canal de WhatsApp em modo de teste sem número autorizado passa a ter aviso próprio na Central (doc 11, decisão B do dono).** Acrescenta `canal_mudo_sem_numero` ao CHECK de `agent_inbox_items.kind`. O canal nasce mudo de propósito — ninguém recebe resposta automática por acidente durante a configuração —, e o defeito é o ESQUECIMENTO: as mensagens chegam no Inbox, tudo parece funcionar e a IA nunca responde, então quem instalou conclui que o produto está quebrado. Quem emite é o cron `canal-mudo-watcher` (diário) depois de 3 dias, e quem FECHA é a mesma rodada, quando o canal ganha número, sai do modo de teste ou é arquivado. **A lista vem INTEIRA de propósito:** `add constraint` substitui a constraint, então reconstruir com lista parcial apaga os outros kinds em silêncio — sem erro de SQL e sem conflito de git. Derivada do `baseline.sql` no momento do commit; a igualdade com o union `InboxKind` é vigiada por `tests/invariants/vocabulario-banco-x-typescript.test.ts`, e a lista que não pode encolher por `tests/invariants/canal-mudo-kind-aceito-pelo-banco.test.ts`. Aditiva: nada a corrigir antes. |
| `20260919150500` | `0326_reconciliacao_das_branches_represadas` | **O encontro de duas branches que reconstruíram os MESMOS objetos.** Esta branch (casos vivos) ficou represada enquanto `0312` refez `agent_inbox_items_kind_check` sem `aviso_de_caso_nao_entregue` (da `0292`) e `0322` redefiniu `fn_atrito_metrics` sem `repeticao_pos_passagem`/`passagens_medidas` (da `0294`). Nenhum dos dois lados errou — cada um reconstruiu a partir do estado que via; o defeito é do ENCONTRO. Forward-fix porque editar `0312`/`0322` quebraria quem já as aplicou. Afirma a UNIÃO **medida**: CHECK com os 28 valores de `0312` + `aviso_de_caso_nao_entregue` (29); `fn_atrito_metrics` com o corpo de `0322` (incluindo `envios_por_automacao`, `envios_por_integracao` e a regra `moved_to_another_pipeline` da `0266`) MAIS o bloco do laço de retorno da `0294`. Sem a reconciliação, quem aplica a CADEIA fica sem o kind (todo aviso de caso não entregue recusado pelo CHECK) e sem a medida que diz se a passagem para humano serviu. Idempotente — **desde a correção**: a primeira versão prometia `drop constraint if exists` no cabeçalho e o SQL não o tinha; em banco que segue a cadeia o `add` falhava com 42710 (a constraint existe desde a `0050`) e a aplicação parava aqui. **Editada depois de entrar na `main`, por exceção consciente:** forward-fix não serviria, porque a cadeia nunca chega nela, e editar é seguro, porque esta migration não pode ter rodado com sucesso em banco que segue a cadeia. O kit self-host não foi afetado (aplica o `baseline.sql`, onde o bloco sempre foi drop + add). Reprodução (pg17 + prelude do `test-db.sh`, `ON_ERROR_STOP=1`): sem o drop, `rc=3 already exists`; com o drop, `rc=0` e reaplicada `rc=0`. **NÃO MEDIDO:** se alguma instância do mantenedor aplica a cadeia (MCP ou `db push`) e contornou a falha à mão — aí o que rodou lá difere deste arquivo. Cerca: `tests/unit/reconstruir-constraint-derruba-antes.test.ts`. |
| `20260919160000` | `0335_marcador_do_contato_normalizado` | **O marcador de contato já gravado passa a caixa baixa.** A escrita passou a normalizar (corta as pontas, minúsculas, teto de 40 caracteres, sem repetição) nos quatro caminhos — ficha (`NewContactDialog`/`EditContactDialog`), importação por CSV (`lib/contacts/csv.ts`), API (`contactCreateSchema`/`contactPatchSchema`/`contactListQuerySchema`) e `crm_manage_tags` — pela MESMA função que o filtro usa para ler (`lib/contacts/tag-normalizada.ts`), o que faz `?tag=vip` encontrar o contato marcado como "VIP". As duas sentenças `update` são o backfill do dado anterior: idempotentes por `is distinct from` (nenhuma linha tocada na segunda execução), ordem de primeira aparição preservada por `with ordinality` para a ficha não reembaralhar os marcadores, e teto de 40 igual ao do código. Issue #1224 (triagem do #1206). Apêndice no baseline antes das travas de suporte. |
| `20260919190000` | `0336_cor_das_etiquetas` | **A etiqueta ganha cor — e a cor que existia só na leitura passa a ter escritor, chip e filtro (issue #1271, fatia S6 da #852).** `fn_vocabulario_de_tags` devolvia `cor`/`descricao` desde a 0264 e NADA escrevia nem pintava: superfície de leitura sem escritor. A operação ganha a ação `definir_cor`, com o parâmetro NOVO `p_cor text default null` — parâmetro novo porque `p_destino` é nome de etiqueta em toda a função, e hex por ali faria a validação de tag valer para cor; `default null` para as chamadas de quatro argumentos seguirem válidas, e `drop function … (uuid, text, text, text)` para as duas assinaturas não coexistirem (medido no PostgREST: com as duas no catálogo, a chamada de quatro chaves resolvia na ANTIGA e a cor nunca chegava ao banco — a tela dizia "atualizada" e o vocabulário não mudava). A ação sai ANTES dos laços, com `return` cedo: cor é atributo do VOCABULÁRIO (`organizations.settings.tags`), nunca das linhas — `contacts.tags`, `crm_leads.tags` e `conversations.tags` seguem `text[]` de nomes (contrato de automação, webhook `lead.tag_added` e MCP `*.tags_changed`, fatia S4) — e o bloco de `canonical_conversation_tags`, que troca o nome da semente pelo destino, APAGARIA a semente com destino nulo. Forma validada (`^#[0-9a-f]{6}$` → `cor_invalida`, 22023) e papel (`manager`) antes de escrever; "sem cor" é `null` e limpa. **Dar cor é curar:** etiqueta que só existia em uso (ou como semente) ganha entrada no vocabulário curado — senão a tela ofereceria cor para uma linha que a leitura ainda marcaria como "em uso, fora do vocabulário". O desempate do dedupe é o MESMO da função de leitura (`(cor is not null or descricao is not null) desc`), senão uma entrada duplicada (string + objeto) apagaria a cor recém-escolhida na primeira execução; `cor` e `descricao` sobrevivem ao rename, que já mexia só em `{tag}`. Na borda: `lib/schemas/tags.ts` ganha a ação e o campo `cor` (normaliza caixa, aceita `#abc` → `#aabbcc`, recusa `destino` fora do rename e `cor` fora do `definir_cor`), a rota passa `p_cor` sempre e registra a cor no audit, e a leitura leve `app/api/v1/tags/cores/route.ts` (`viewer`, uma linha de `organizations`, sem MFA) alimenta o chip de quem atende — separada da leitura pesada, que conta uso nas três tabelas e exige `manager`. Na tela: `components/tags/ChipDeEtiqueta.tsx` nos oito lugares que renderizavam `Badge` neutro (lista de conversas, editores do Inbox, painel do CRM, lista e ficha de contatos, tabela e painel de Tags), cor no filtro de etiqueta do Inbox (chip no gatilho, ponto na opção), do funil e de Contatos, e a fileira de oito tons no painel de Tags, com prévia antes de salvar. A paleta (`lib/tags/cor-da-etiqueta.ts`) foi ESCOLHIDA POR BUSCA com as duas réguas de `lib/branding/contraste.ts`: pior par a olho nu 0,1195 (piso 0,10), pior sob dicromacia 0,0576 (piso 0,05) e pior contraste do texto 4,75 (WCAG 4,5) — escolher a olho dava dois vermelhos separáveis sob deuteranopia e iguais para todo mundo; o texto do chip sai de `melhorFrenteSobre`, nunca do gosto de quem escolhe a cor. Cada tom tem NOME ("Âmbar", "Roxo") porque cor não pode ser o único jeito de escolher. Gate: `tests/invariants/tags-cor-de-etiqueta.test.ts` (Postgres: persiste e volta pela leitura, hex inválido não escreve nada, `viewer` recusado, cor de uma organização não aparece na outra) — e `tests/unit/tags-cor-de-etiqueta.test.ts` cobre o contrato do schema, da paleta e do chip, com `tests/unit/tags-vocabulario.test.ts` atualizado para a assinatura de cinco argumentos. |
| `20260919235000` | `0340_modulo_instalado` | **Instalar um módulo opcional na INSTÂNCIA cria as tabelas dele na hora, e a atualização reaplica e falha alto — D3 e D6 da ADR-0002 (onda 2, issue #1114), sobre o molde da 0325.** Tabela `modulos_instalados` (sem `organization_id`: o corte é por instalação, decisão do dono; RLS ligada, zero policy, `anon`/`authenticated` sem privilégio). `fn_modulo_instalar(p_actor, p_operation, p_modulo)` (`security definer`, só `service_role`): confere o ator como admin de plataforma, pega a MESMA trava das extensões (`pg_advisory_xact_lock(255,1)`), é idempotente pelo recibo em `extension_operations` (tipo novo `module_install`, recibo de plataforma), recusa módulo cuja `fn_<modulo>_provisionar()` não existe (`extension_module_unknown`) e recusa durante atualização do núcleo; chama a provisionadora, registra o módulo e emite `pg_notify('pgrst','reload schema')` — sem isto a tabela nova seria 404 na API até o próximo restart. A lista de módulos instaláveis é o conjunto de provisionadoras que EXISTEM; não há segunda lista para divergir. `fn_reaplicar_modulos_instalados()` roda cada provisionadora de novo; falha de um módulo marca `suspenso` com o motivo e NÃO relança — a marca se confirma sozinha porque o kit aplica sem transação única. `fn_conferir_modulos_instalados()`, num comando SEPARADO e último do baseline, levanta ERROR se há módulo suspenso, com texto que não casa com `BASELINE_ERROS_BENIGNOS` do kit (o motivo original fica na coluna, nunca na mensagem, porque um `already exists` ali seria engolido). As duas sem grant: só quem aplica o schema as chama. Instalação nova e todo banco de hoje: zero módulos, as duas chamadas são no-op. Invariantes: `tests/invariants/modulo-instalado.test.ts` (as funções) e `tests/invariants/modulo-suspenso-o-kit-reporta.test.ts` (o rodapé REAL do baseline aplicado com `psql -q -f` sem `ON_ERROR_STOP`, como o kit, filtrado pela expressão lida do `_common.sh`). |
| `20260919234000` | `0342_catalogo_deepseek` | Semeia o catálogo da DeepSeek em `ai_models` e o preço em `ai_pricing`: `deepseek-flash` e `deepseek-v4-pro`, `supports_tools = true`. É o "próximo provedor" que a 0127 (vocabulário aberto de `provider`) existia para destravar — OpenAI-compatível, então entra pela mesma fábrica `@ai-sdk/openai` com base URL própria, sem SDK novo. **Ids e preços verificados no provedor**, não inventados: `GET https://api.deepseek.com/models` devolve os dois ids, e a doc oficial publica entrada (cache miss) $0,14/1M e saída $0,28/1M → **14 e 28 centavos por milhão** nas duas tabelas. O preço de cache hit ($0,0028/1M = 0,28¢) **não entra**: a coluna `input_price_per_million_cents` é `integer` e guarda o preço cheio de entrada; o desconto de prefixo é aplicado na cobrança do provedor, não é preço de catálogo — fica documentado para quem for somar cache no orçamento. **Sem `is_default_for_provider`**: o padrão por provedor é curadoria dos três semeadores originais e o invariante `catalogo-de-modelos` espera exatamente um para anthropic/openai/google; a escolha recai no mais barato com ferramentas (`escolherModeloDoProvedor`), como na OpenRouter. Sem coluna nova, sem função, sem backfill de dado existente. Idempotente (`on conflict do update`), baseline INSTALL/UPDATE idempotente. Catracas: `tests/unit/provedor-deepseek.test.ts` e o par `tests/unit/provedores-x-registry.test.ts`. |

| `20260919160431` | `0343_agenda_dos_colegas` | **A agenda dos colegas vira OPÇÃO POR ORGANIZAÇÃO, LIGADA por padrão (issue #978).** A decisão do mantenedor no fio da issue (16/09) NÃO foi uma guarda fixa: é `settings.colegas_podem_mexer_na_agenda` (booleano no topo do jsonb, **chave própria e não `settings.agenda`**) — `fn_agenda_settings` substitui o objeto `settings.agenda` inteiro e recusa chave que não conheça (`p_config - 'atraso_de_confirmacao' - 'protecao_sem_resposta' <> '{}'` → `agenda_config_invalida`), então a chave seria recusada por ele e apagada na primeira vez que um Gerente salvasse os prazos; é o mesmo caso que levou `cliente_pela_agenda` para `settings.crm` na 0262. **Ausente = LIGADO, e isso é literal:** a leitura é `(settings->'colegas_podem_mexer_na_agenda') is distinct from 'false'::jsonb`, sem backfill, sem `default` novo e sem linha reescrita — quem já instalou não vê mudança nenhuma. Ligada, tudo é como sempre foi (qualquer Atendente mexe na agenda de qualquer colega); desligada, o Atendente só mexe no compromisso de que é DONO, e Gerente/Administrador seguem mexendo em tudo. **A mesma regra nos DOIS lugares:** `fn_appointment_change_core` (definição em vigor copiada do baseline com UM bloco novo) recusa com `appointment_do_colega`/42501 a mudança de compromisso alheio, e a rota (`app/api/v1/agenda/agendamentos/_handler.ts`, `exigeDonoDoCompromisso`) avisa antes de tocar no banco — inclusive na criação, onde o responsável resolvido pode vir do `default_owner_user_id` do TIPO e não ser quem está pedindo. **Para quem vale (declarado no PR):** pessoa com `auth.uid()` e papel abaixo de manager na opção desligada; IA e integração NÃO são "um atendente" e não têm agenda própria (o que as governa continua sendo o papel do token, que a rota já cobra); canal remoto do Google (`p_remote`, service role, `auth.uid()` nulo) escreve o que o Google mandou. **Porta de escrita:** `fn_definir_colegas_podem_mexer_na_agenda(uuid,boolean)` — Gerente ou acima, suporte de escrita e MFA comprovado, conferidos no corpo pelo `auth.uid()` (o piso é `manager` e não `admin` da 0262: é regra da AGENDA, a mesma tela de `fn_agenda_settings`, e não reescreve dado nenhum), devolvendo `{ligado,mudou}`. Sem tabela, coluna, índice ou policy novos; o apêndice do baseline entra antes do bloco da varredura anon e repete os `revoke` das funções redefinidas. Gates: `tests/api/agenda-cancelar-agenda-do-colega.test.ts` (comportamento no handler que a rota e as ferramentas MCP chamam — as duas posições da opção nos papéis, e a recusa SEM escrita) e `tests/unit/agenda-dos-colegas-e-opcao-da-org.test.ts` (a matriz da régua, o dono ausente, os atores que não são gente e as duas leituras — TypeScript e banco). |
| `20260920000500` | `0344_atribuicao_de_anuncio_exige_organizacao` | **`fn_estampar_atribuicao_de_anuncio` passa a exigir a ORGANIZAÇÃO — escrita cross-tenant barrada no `where` (issue #1248).** A função é `security definer`, executável só por `service_role` (revoke de public/anon/authenticated), e o único limite era o `p_contact` que o CHAMADOR mandava: `where id = p_contact and source_metadata->>'ad_platform' is null`, sem `organization_id` nenhum. O backend resolve o contato pelo canal (ingest de Meta, Google, site, waha), não pelo dono dele — então id vazado, replay de webhook com id trocado ou erro de resolução estampava o anúncio no contato de OUTRA organização: escrita cross-tenant por um caminho que a RLS não vê, porque roda como definer. A organização vira parâmetro OBRIGATÓRIO (`p_org uuid`, sem default — chamada sem ela não compila, que é o ponto) e o `where` casa `organization_id = p_org`: contato alheio casa zero linhas, silenciosamente, como o primeiro-toque (a função nunca levanta erro — quem estampa anúncio não pode derrubar o atendimento por causa de atribuição). A assinatura antiga de TRÊS argumentos é derrubada ANTES do create (`drop function if exists … (uuid, text, jsonb)`), pela mesma lição medida na 0336: com as duas no catálogo, a chamada de três chaves resolveria na ANTIGA e a organização nunca chegaria ao `where` — o defeito continuaria de pé com a função nova parecendo aplicada. `comment on function` atualizado, par `revoke`/`grant` realinhado para a assinatura de quatro argumentos (`uuid, uuid, text, jsonb`) e `notify pgrst, 'reload schema'`. Sem coluna nova, sem backfill, sem mudança de política para `anon`/`authenticated` (que nunca executaram isto). Nos chamadores, a organização que já estava em mãos passa a ser argumento: `lib/leads/atribuicao-de-anuncio.ts` (organização da plataforma de anúncio) e `lib/leads/origem-do-site.ts` (organização da entrada), com os testes de unidade dos dois passando a exigir `p_org` no payload da RPC. Invariante: `tests/invariants/atribuicao-do-anuncio-nao-atravessa-organizacao.test.ts` (Postgres: sonda de catálogo provando que só existe UMA assinatura, controle positivo de `service_role` estampando na própria organização, chamada com a organização ERRADA não escrevendo no contato alheio, e `has_function_privilege` seguindo `t,f,f` para service_role/anon/authenticated). |
| `20260920003000` | `0359_cascata_lgpd_alcanca_a_comanda` | **A cascata de anonimização passa a alcançar `sales`.** O módulo financeiro (0350-0357) trouxe uma tabela com FK para `contacts` e três colunas de texto livre que nomeiam a pessoa de novo (`notes`, `cancel_reason`, `reverse_reason`) — fora da cascata, anonimizar devolvia SUCESSO com o texto legível, e o SLA de D+15 era marcado como cumprido sobre dado que não saiu. Forward-fix da função do NÚCLEO, não edição da 0351: o passo pertence à cascata. PRESERVA valor, status, datas e o VÍNCULO com o contato — a venda é registro financeiro, e desligá-la faria o relatório por cliente deixar de fechar com o faturamento do período; o contato apontado já é `Cliente Anonimizado #N`. `sale_items` fica de fora de propósito: `description` ali é o nome do SERVIÇO, congelado na inclusão, e apagá-lo destruiria o relatório por serviço sem tirar dado de pessoa nenhum. `create or replace` da função inteira porque ela percorre uma lista escrita à mão. Apêndice no baseline ANTES do bloco VARREDURA anon. Vigiado por `tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts`, que deriva o escopo do catálogo e lê o corpo REAL instalado. |
| `20260920000000` | `0341_configuracao_da_instalacao_pela_tela` | **A configuração da INSTALAÇÃO passa a caber na tela**, generalizando o caminho que a marca (0155) e a credencial do Google (0201) já tinham aberto: `public.platform_config`, uma linha por variável, nomeada como a própria variável de ambiente (CHECK `^[A-Z][A-Z0-9_]{2,63}$`, para que a correspondência banco ↔ `.env` seja literal e conferível por quem opera a VPS). **Linhas e não colunas**, por duas razões medidas: (1) escala — são 42 chaves candidatas (27 migráveis + 15 knobs) e, cifrada, cada credencial ocupa quatro campos, o que levaria a tabela a 150+ colunas e faria de cada chave nova um ALTER; (2) o tudo-ou-nada documentado no cabeçalho de `lib/branding/instalacao.ts` — coluna nova em singleton faz o PostgREST devolver `42703` para a LINHA INTEIRA quando o código é novo e o schema é velho, derrubando os outros campos junto. Numa tabela de linhas esse modo de falha não existe: chave que o banco ainda não tem é linha ausente, e linha ausente JÁ significa 'usa o `.env`'. **A cifra é da APLICAÇÃO, não do banco, e a escolha não é estética.** O repositório tem os dois padrões convivendo: `fn_encrypt_oauth` (0201/0257) cifra com `pgp_sym_encrypt` e guarda a chave em `private.app_secrets`; `lib/crypto/aes_gcm.ts` cifra AES-256-GCM e guarda a chave só no `.env` (`AI_CRED_AES_KEY`, nove consumidores hoje). O `backup.sh` do kit roda `pg_dump` **sem filtrar schema**, pela mesma conexão privilegiada que semeia a chave — se a semeadura alcança `private.app_secrets`, o dump alcança —, e o backup **não leva o `.env`** (só banco + sessões do WhatsApp). Logo, com a cifra do banco, um backup vazado entrega a chave e o cofre no mesmo arquivo: tolerável para um segredo do Google, inaceitável quando o cofre guarda todas as credenciais da instalação. Esta tabela guarda o envelope cru (`ciphertext`/`iv`/`tag`) e **nenhuma função do banco sabe abri-lo**. CHECK `platform_config_forma_do_valor` faz o XOR entre segredo e knob: sem ele, uma linha poderia ter `valor` em claro E envelope cifrado, e o resolvedor teria de escolher — que é como um segredo vaza em claro. `last4` existe para a tela dizer QUAL chave está lá sem devolver o valor. `semeado_do_env` não é enfeite de proveniência: é o que impede o `.env` de desfazer escolha humana (regra portada de `precisaSemear`, 0155) — linha com `false` nunca é re-semeada, **inclusive quando está vazia**, que é o caso que mata o campo (a pessoa apaga para voltar ao padrão e o render seguinte reescreve o valor antigo). Apagar a LINHA é o 'voltar ao padrão', por isso `delete` entra no grant. RLS ligada com **zero policies**, de propósito e pela mesma razão de `platform_branding`/`platform_settings`: a linha não pertence a organização nenhuma, então não há predicado de tenant que a isole; ninguém alcança pela REST e quem lê é o `service_role`, do servidor. `revoke all ... from anon, authenticated` explícito porque o `alter default privileges ... on tables to anon` do baseline alcança toda tabela criada depois dele — isto é, todo apêndice novo. Sem `organization_id`, logo fora da varredura de `fn_aplicar_travas_de_suporte()` (0274), e o apêndice entra **antes** do bloco final do baseline, que tem de continuar sendo o último. Sem dado tocado, sem backfill: tabela nova e vazia, e o resolvedor degrada para o `.env` enquanto assim estiver. Regra pura e testável sem Postgres em `lib/instalacao/config-resolve.ts` (11 casos em `config-resolve.test.ts`). |
| `20260920120000` | `0364_a_poda_de_nonces_aceita_os_nomes_das_irmas` | **A poda de nonces de OAuth volta a apagar — e a ATUALIZAÇÃO recebe o conserto.** O cron `data-retention` drena todas as podas pelo mesmo laço de lotes, mandando sempre `{ p_retencao_dias, p_limite }` (`app/api/v1/cron/data-retention/route.ts:163`), e o PostgREST resolve sobrecarga pelo NOME do argumento, nunca pela posição: `public.fn_expurgar_nonces_de_oauth`, nascida na 0190 com `(p_dias int, p_lote int default 500)`, recebia os mesmos dois `int` com outros nomes, e por isso não achava sobrecarga nenhuma — `PGRST202` todos os dias, `public.calendar_oauth_nonces` (a tabela que impede reuso do `state` do login Google) crescendo para sempre, e, no mesmo bloco do cron, a varredura de anonimizações LGPD interrompidas (`route.ts:271`) inalcançada, porque roda depois das podas. O defeito é o nome: as outras SEIS funções que o mesmo laço chama já falam `p_retencao_dias`/`p_limite`, e esta era a sétima. **Por que o drop:** `create or replace` NÃO troca nome de parâmetro de entrada — o Postgres recusa com "cannot change name of input parameter", porque o nome faz parte da identidade da função para quem chama por nome. Sem `drop function if exists public.fn_expurgar_nonces_de_oauth(int, int)` ANTES do `create`, numa instalação que já existe o conserto não entra: é a mesma razão pela qual o trecho correspondente do `supabase/baseline.sql` também derruba antes de recriar (o `update.sh` reaplica o baseline inteiro, e é por ali que a instalação existente recebe o conserto). O drop leva os ACLs junto, então o `revoke execute ... from public, anon, authenticated` e o `grant execute ... to service_role` da 0192 são reaplicados — não é redundância com a 0192, é o que repõe o estado que ela deixou. **Aditiva e sem dado tocado:** não há `insert`, `update` nem `alter table`; muda o NOME dos parâmetros e nada mais. A assinatura `(int, int)` é a mesma, o piso de 1 dia em `p_retencao_dias` continua no corpo e o 500 que a 0190 declarava para o lote continua valendo no corpo (`coalesce(p_limite, 500)`), como nas irmãs. Os nonces vencidos enquanto o cron falhava são apagados pelo primeiro lote bem-sucedido. |
| `20260921020000` | `0367_interface_por_empresa` | **O menu lateral passa a ser escolhido pela EMPRESA, não só pelo vínculo (issue #1341).** `organizations` ganha `interface_settings` jsonb (`not null default '{"preset":"completa"}'`) com a MESMA forma da 0221 — `{preset, destinos?}` — um degrau acima: a organização escolhe o universo de portas da instalação e cada vínculo escolhe menos dentro dele. A leitura passa a resolver EMPRESA ∩ VÍNCULO ∩ papel, com as portas essenciais sempre de pé. Default `completa` para toda organização existente: quem não mexer em nada não vê diferença. É apresentação: não participa de RLS, policy nem autorização — o papel continua decidindo página, API e ação. |
| `20260921040000` | `0372_banco_externo_do_agente` | **Recorte do PR #1130, de @vgamkt: a organização passa a cadastrar um PostgreSQL de OUTRO sistema (segundo CRM, ERP) para o CRM consultar em tempo real.** Tabela `external_db_connections` (tenant-aware, `organization_id not null` com cascade), a senha em três colunas `bytea` (`password_encrypted`/`password_iv`/`password_tag`) cifradas pelo APP com AES-256-GCM e `AI_CRED_AES_KEY` — o mesmo padrão de `ai_provider_credentials` (0023), e não `fn_encrypt_oauth` (0201), porque quem abre a conexão é o Node via `pg`: a chave tem de estar no processo, e cifrar no banco exigiria mandar a senha em claro só para decifrá-la depois. Nunca há coluna em claro. **Por organização e não por instalação**, pelo mesmo motivo de `ad_platform_connections` (0213): duas empresas na mesma VPS têm bancos externos diferentes, e um singleton faria o agente de uma responder com dado da outra. `ssl_mode` default `require` (remoto sem TLS manda credencial e dado em claro pela rede), CHECK do vocabulário `disable|prefer|require|verify-ca|verify-full`; `enabled` para pausar sem reapontar host/porta/credencial; `unique (organization_id, label)` porque dois cadastros de mesmo nome deixariam tela e agente ambíguos. **RLS em duas policies, não uma:** `select` para qualquer membro da organização (decisão do dono — saber que a fonte existe não é segredo) e `for all` exigindo `fn_role_at_least(organization_id,'admin')` para escrita, de modo que um `viewer` falando direto com o PostgREST não reconfigura a credencial. O segredo não sai do servidor: a view `external_db_connections_safe` (`security_invoker`, logo herda a RLS da base) omite as três colunas cifradas e é a única com `grant select to authenticated`; `revoke all ... from anon` na tabela e na view, porque o `alter default privileges ... on tables to anon` do baseline alcança toda tabela criada depois dele. Triggers `fn_set_updated_at` e `fn_audit_log_row`. **Nenhuma função nova em `public`** ⇒ o item 9 da doutrina de migrations não é acionado. O schema do banco externo NÃO é espelhado de propósito: ele muda com frequência e cópia de schema envelhece — a introspecção é ao vivo, em `lib/external-db/introspeccao.ts`. Numerada `0233` na branch de origem; realocada no recorte porque 0233/0234 já foram para outra coisa na `main`. Apêndice idempotente no `baseline.sql` ANTES da reaplicação de módulos e das varreduras finais (tabela de organização nova tem de passar pelas travas de suporte na PRIMEIRA aplicação). |
| `20260921040100` | `0373_limites_configuraveis_do_banco_externo` | **Os tetos de leitura do banco externo deixam de ser cravados no código e passam a ser propriedade da CONEXÃO.** `max_rows` (1..5000, default 200 — o antigo `LIMITE_MAX`), `max_filters` (0..100, default 20) e `max_response_bytes` (4096..1048576, default 30000), as três `integer not null` com CHECK. Colunas tipadas e não um `jsonb` de configuração porque o limite pertence à conexão (cada fonte tem um processo), não à organização nem à instalação — e CHECK diz a mesma coisa que um JSON sem validar nada, com RLS e PostgREST herdando a proteção de graça. Defaults IGUAIS aos valores antigos: ninguém muda de comportamento sem pedir. `max_rows` governa a grade da tela e o teto da consulta; `max_filters` e `max_response_bytes` governam o que entra no contexto do modelo. A view `_safe` é recriada expondo os três (são configuração, não segredo) com os grants refeitos logo abaixo. **Doutrina item 8 no apêndice do baseline:** os três `update` de clamp rodam ANTES do `add constraint`, para que o `update.sh` de um clone onde alguém escreveu um teto fora da faixa direto no SQL conserte em vez de quebrar. Recorte do PR #1130, de @vgamkt; `0234` na branch dele, renumerada aqui. |
| `20260921060000` | `0375_campanhas` | **Campanhas** (Sub-PRD 12 / Spec 12 / Spec 13): `campaigns` e `campaign_recipients`. Duas tabelas, e só. **Nenhuma `channel_send_protection`** (Spec 13 §4.1): proteção de envio por conexão já existe neste repo com tela — `channel_knobs` + `channel_sessions.daily_message_limit`, editados pela `AntiBanSheet` (título literal: "Proteção de envio") e respeitados por `decidePacing` com contador real em `pacing_ledger`; a tabela da spec daria duas janelas e dois tetos por conexão, e alguém teria de decidir qual ganha em cada caminho de envio (anti-pattern nº 2). A campanha ganha só o OVERRIDE dela (`intervalo_segundos`, `janela_*_hora`, `teto_diario`, `teto_horario`, todos nullable = herda do canal), sempre o mais restritivo dos dois. Sem `campaign_templates`, sem `campaign_suppressions`, sem `message_mode`/`provider_template_*` — decisão do dono (2026-09-18): MVP é texto livre por WAHA. `base_legal` **sem default** (Regra nº 1: default plausível de base legal pareceria configurado e não estaria) com `lia_ref` obrigatório em `legitimate_interest` — mesma régua do gate de LGPD da cadeia de envio, e do LIA-2026-01. FK composta `(organization_id, channel_session_id)` (padrão 0260/0262) com `on delete restrict`: apagar o número apagaria para quem a campanha falou. Dedup em DUAS unicidades — por contato e por endereço —, porque dois cadastros gêmeos com o mesmo telefone são uma pessoa só. Trigger `trg_messages_sincroniza_campanha` (AFTER UPDATE OF status em `messages`) leva o ack ao destinatário: **não há hook de aplicação para mudança de status** (`trg_messages_emit_event` é AFTER INSERT — é por isso que o `recover-stuck-messages` emite `message.failed` à mão), e a alternativa era polling. Status analítico nunca retrocede: `read` não volta a `delivered`, `replied` não volta a nada. RLS no padrão da 0261 (SELECT ao tenant, escrita a partir de `manager` — `rbac-config-ia-canais.test.ts` proíbe policy `ALL` só-tenancy em tabela nova). |
| `20260921060100` | `0376_campanhas_templates_e_exclusoes` | **Templates e lista de exclusão de campanha** (Spec 12 §2.3 e §2.4), pedidos na tela depois de a 0375 subir. `campaign_templates` guarda a copy FORA da execução — hoje o texto vive dentro de uma campanha, e quem tem uma abordagem que funciona copia e cola até a versão boa e a velha ficarem indistinguíveis; editar um template amanhã não reescreve o que alguém recebeu ontem, porque o conteúdo é congelado por destinatário com `content_version`. `campaign_suppressions` é a exclusão da OPERAÇÃO ("não mande campanha para este número"), deliberadamente diferente do opt-out, que é do TITULAR e vive em `contacts.is_blocked` — a suppression não mexe no cadastro nem no que o agente responde a quem escreve. Guarda **hash** do telefone (SHA-256 do E.164) mais os últimos dígitos para a tela dizer de quem é a linha: lista de "não mandar" não precisa virar um segundo lugar onde telefone de gente mora, e por isso ela fica fora da cascata de anonimização (não há PII a redigir; `contact_id` sai sozinho pelo `on delete set null`). RLS no padrão da 0375: SELECT ao tenant, escrita a partir de `manager`. Unicidade por `(organization_id, name)` nos templates e por `(organization_id, recipient_address_hash)` nas exclusões. |
| `20260921060200` | `0377_campanha_rodizio_de_numeros` | **A campanha passa a falar por VÁRIOS números.** `campaign_channel_sessions` (tabela de vínculo, não array: com array nenhuma FK impede o número de OUTRA organização entrar na lista, e a checagem viraria código que alguém esquece — com linha, a FK composta `(organization_id, channel_session_id)` recusa no banco, padrão da 0260/0262/0375) e `campaign_recipients.channel_session_id` nullable, preenchida NO ENVIO. Aditiva: `campaigns.channel_session_id` continua obrigatório e é o número principal, então toda campanha existente segue funcionando sem uma linha sequer na tabela nova, e quem não quer rodízio nunca abre essa parte da tela. A escolha é dinâmica (decisão do dono, 2026-09-19): a cada envio ganha o número com mais folga no teto do dia, **exceto** quando o contato já conversa com um dos números do pool — aí vence o histórico, que é a mesma regra que a cobrança do Asaas usa, porque receber de um número desconhecido separa a mensagem do histórico da pessoa. Regra pura em `lib/campanhas/rodizio.ts`, com teto desconhecido tratado como MENOS preferido (tratá-lo como infinito concentraria no número sem configuração o volume que o rodízio existe para espalhar). Número fora de `WORKING` não entra no rodízio. O ritmo da CAMPANHA (intervalo, tetos) continua somando todos os números — o rodízio só aumenta volume quando a campanha herda o ritmo de cada número. |
| `20260921060300` | `0378_campanha_funil_e_agente` | **A campanha declara onde o card nasce e quem atende.** Três colunas nullable em `campaigns` (`pipeline_id`, `stage_id`, `agent_id`) com FK COMPOSTA `(organization_id, …)` e `on delete set null`, mais `campaigns_etapa_exige_funil` (etapa sem funil seria card sem coluna) e o índice `idx_campaign_recipients_conversa`, que o degrau novo do roteamento consulta a cada turno. **Funil:** hoje quem decide é o NÚMERO (0262); a campanha VENCE quando declara — decisão do dono (2026-09-19), porque é a escolha mais específica e quem montou a campanha sabe onde quer medir. Sem declarar, nada muda para ninguém. **Agente:** paga uma dívida que o repo já tinha DECLARADO por escrito em `lib/ai/elegibilidade/campanha.ts` ("encaminhar por campanha exige levar o agent_id e o resolve-turn-agent respeitá-lo — não feito nesta entrega"), onde o campo já existia e era ignorado. `resolveTurnAgent` ganha um degrau ACIMA do roteador: o roteador é do número e classifica assunto, a campanha sabe que a pessoa está respondendo a uma abordagem. Só assume conversa que NASCE da campanha — cliente antigo que responde a uma reativação segue com quem já o atendia. Falha ABERTA: agente sem versão publicada cai na régua do número em vez de calar o atendimento, e isso é medido por sabotagem no teste. O card também nasce marcado (`crm_leads.source='campanha'` + id e nome no metadata), vencendo a origem de anúncio — o anúncio trouxe o contato meses atrás, mas este negócio nasceu porque nós falamos com ele. |
| `20260921060400` | `0379_campanha_na_cascata_lgpd` | **A campanha entra na cascata de anonimização.** `campaign_recipients` guarda a MENSAGEM dita à pessoa (`rendered_body`) e o telefone (`recipient_address`); `campaign_suppressions` guarda a cauda do número. Nenhum dos dois estava em `fn_lgpd_cascade_redact_contact` — anonimizar devolvia SUCESSO e a prospecção continuava legível, falha muda com o SLA marcado como cumprido (medido pelo invariante `lgpd-cascata-alcanca-quem-guarda-pessoa`). Duas decisões deliberadas: a LINHA de `campaign_recipients` fica (é a prova de que a pessoa esteve na campanha; apagá-la desfaria a contagem de quem recebeu) e o HASH de `campaign_suppressions` fica (é ele que faz o "não me mande mais" continuar valendo depois da anonimização — apagá-lo faria a pessoa voltar a receber). Redefine a função inteira a partir da definição VIGENTE no baseline, como a 0235. |
| `20260921080000` | `0381_meta_ads_captura_de_utm` | **O ref curto que carrega as UTMs da landing page até a conversa do WhatsApp — a captura de clique da 0306 espelhada para o caso que faltava.** Das três fontes que chegam ao WhatsApp, duas já rastreiam sozinhas (o `referral` nativo do clique-para-WhatsApp e o POST do formulário, que `lib/webhooks/inbound.ts` aceita com qualquer chave `utm_*`); sobra a página com BOTÃO de WhatsApp, e o motivo é físico: o link `wa.me` não fala com o CRM — abre o aplicativo no aparelho da pessoa —, então a origem tem de viajar dentro do TEXTO da mensagem. O contrato `[dk1:<base64url>]` (`lib/leads/origem-do-site.ts`) já fazia isso e **continua valendo, sem depreciação**; o que muda são os dois custos de uso dele: cerca de 200 caracteres de base64 visíveis para o lead, e o script que quem monta a página precisaria colar (o marcador tem de ser gerado POR VISITANTE, e link estático não gera nada). Duas tabelas, espelhando a 0306: `meta_ads_landing_pages` (para qual WhatsApp e com qual texto a rota redireciona, com `check` exigindo o literal `{token}` no template) e `meta_ads_click_refs` (o par ref↔UTM, único em `(organization_id, token)`, com `check` de `utm` não vazio porque ref que não aponta para origem nenhuma só sujaria a mensagem do lead). `utm` é jsonb com as MESMAS dez chaves de `CHAVES_DE_UTM`, já normalizadas pela mesma função que o `[dk1:]` usa — sem vocabulário novo. **Duas tabelas e não uma com coluna de plataforma**, pela razão do cabeçalho da 0306: cada eixo de captura nasce e funciona sozinho, e mexer no par do Google para acomodar o da Meta faria uma landing page em produção depender de uma troca de chave primária que ela não pediu. Server-side only nas duas: RLS ligada, ZERO policies, `revoke all` de anon/authenticated, `grant` só para `service_role` — a UTM diz quanto e onde a organização gasta, e quem lê é o servidor com o admin client filtrando `organization_id` à mão. Sem cron de limpeza, igual à irmã do Google: linha nunca casada fica, e isso é dívida declarada, não omissão. Apêndice idempotente no `baseline.sql` logo depois do bloco da 0306, antes da VARREDURA anon. Gates: `tests/invariants/captura-de-clique-e-server-side.test.ts` (o `describe.each` da 0306 passa a medir as quatro tabelas) e `tests/unit/meta-ads-captura-de-utm.test.ts` (o gerador de ref e os três caminhos ruins da rota pública, todos devolvendo o WhatsApp). |
| `20260921070000` | `0380_hierarquia_do_anuncio` | **O anúncio tem nome, e o CRM só tinha o número dele.** `ad_hierarchy_cache` guarda nome do anúncio, do conjunto e da campanha por `(organization_id, platform, ad_id)`. O contato que chega por clique-para-WhatsApp traz só o id do anúncio — a plataforma não manda nome nenhum no `referral` —, e os nomes existem apenas na API, se alguém perguntar. **O cache não é otimização prematura, é a condição para a feature durar um dia:** a conta sondada responde `ads_api_access_tier: "development_access"` (medido, registrado no cabeçalho de `lib/plataformas-de-anuncio/meta/insights.ts`), e um anúncio que presta gera centenas de contatos — sem cache, cada ficha aberta repete a MESMA pergunta sobre o MESMO anúncio e a cota acaba no primeiro dia, com o operador vendo a ficha sem nome de campanha, igual a antes da feature existir. `fetched_at` guarda quando se perguntou; **a idade aceitável é decisão do código que lê, não do schema** — ela muda com o degrau de cota da conta, não com a forma do dado. `organization_id` entra no índice único porque o id do anúncio é da plataforma e duas organizações podem alcançar a MESMA conta (uma agência e o cliente dela): sem ele, a segunda a resolver leria a linha da primeira e mostraria, na ficha do contato dela, o nome que o vizinho deu ao anúncio — a mesma lição da #236. Sem FK nos identificadores: o anúncio é da plataforma e pode ser apagado lá sem aviso. Quarta tabela do eixo com o desenho server-side-only de `ad_platform_connections` (0213), `ad_insights_connections` (0214) e as duas de captura do Google (0306): RLS ligada, ZERO policies, grants de anon/authenticated revogados — nome de campanha e de criativo é a ESTRATÉGIA de mídia de quem anuncia, que um concorrente pagaria para ler, e a anon key vai para o browser. Deny-all é mais restritivo que policy de tenant, não menos; quem lê é o servidor com o admin client filtrando `organization_id` à mão. Nenhuma função nova em `public`. Gate: `tests/invariants/credencial-de-anuncios-e-server-side.test.ts`, que mede privilégio E comportamento (`permission denied` sob `set role`).
| `20260922021548` | `0382_link_salvo_no_modelo` | **O link da mídia fica salvo no modelo.** Modelo com cabeçalho de imagem, vídeo ou documento exige o link em TODO disparo (a plataforma guarda só a amostra da aprovação), e o painel da janela fechada fazia o operador colar a mesma URL a cada vez. `meta_templates.saved_values` (jsonb `not null default '{}'`, CHECK de objeto) guarda os valores a reaproveitar, chaveados como `template_values` no envio (`slotKey`: `header:1`…), então o painel pré-preenche sem tradução. **Coluna no espelho e não tabela nova:** o valor pertence à definição (organização + WABA + nome + idioma), que é exatamente a linha; `syncTemplates` faz `upsert` listando só as colunas da plataforma, então sincronizar não apaga o link. **Só link de mídia:** a rota de escrita (`PATCH /api/v1/channels/templates`, `admin` + `requireSupportWrite`) recusa chave que não seja slot de mídia e valor que não comece com `https://` — `{{1}}` do corpo costuma ser o nome do cliente, e salvá-lo mandaria o nome de uma pessoa para a próxima. Aditiva, sem backfill, sem função, sem policy nova (a tabela segue escrita só pelo servidor). Apêndice no baseline antes da varredura anon, idempotente (`add column if not exists`, `drop constraint if exists` + `add`). |
| `20260922140000` | `0383_pedidos_de_cadastro` | **Cadastro com aprovação: a empresa nova espera o dono da instalação (recorte do PR #714, de @betoarts; decisão do dono, doc 24, Decisão 2 = (d)).** `platform_settings.signup_mode` ganha o terceiro valor `com_aprovacao` (CHECK recriada com `drop constraint if exists` + `add constraint`, superconjunto da anterior; o default continua `aberto`, então nenhuma instalação muda ao atualizar). Tabela `registration_requests` (`user_id`, `requested_organization_name`, `status` pending/approved/rejected, `decided_by`, `decided_at`): o pedido de quem confirmou a conta pelo fluxo normal do provedor e quer abrir empresa numa instalação `com_aprovacao`; a empresa só nasce quando o administrador da instalação aprova em `/admin/cadastro`. Índice único parcial: um pedido pendente por conta. **Da INSTALAÇÃO, não do tenant** — o pedido é anterior à organização, então não há `organization_id` que o isole: RLS ligada, ZERO policies, `revoke all` de anon/authenticated, só service_role, o mesmo desenho de `platform_settings` (0253). Fora do desenho do autor, por bloqueador de segurança publicado no #714: pedido para ENTRAR em empresa existente (exigia listar as empresas a visitante anônimo — quem entra numa empresa existente entra pelo convite) e qualquer confirmação de e-mail sem prova de posse. Nenhuma função nova em `public`. Na branch do autor era `20260912115014_solicitacoes_de_cadastro`, sem número. |
| `20260922190000` | `0384_banco_externo_vira_modulo_opcional` | **O banco de dados externo vira MÓDULO OPCIONAL da instalação, desligado por padrão (doc 37; completa o #1372).** A chave é a linha `MODULO_BANCO_EXTERNO` de `platform_config` (0341), ligada e desligada por quem administra o servidor em `/admin/sistema`; sem linha = desligado (`lib/instalacao/modulos.ts`). Desligado: nenhuma porta no menu, `/app/integracao-dados` e `/api/v1/external-db/*` respondem 404, `abrirAcesso` recusa, e as ferramentas de IA do módulo não são oferecidas ao agente. **Dado tocado:** grava a linha SEMPRE na primeira aplicação — `ligado` se a instalação já tinha conexão cadastrada, `desligado` se não (`case when exists ... on conflict do nothing`). A atualização não tira a função calada de quem usava, e a reaplicação do baseline a cada `update.sh` nunca reescreve a linha: sem ela, uma conexão escrita depois pelo admin de uma empresa (PostgREST, com o módulo desligado) ligaria o módulo para a instalação inteira na atualização seguinte. Não é coluna de `platform_settings` porque criar a linha daquele singleton faria `signup_mode` nascer `'aberto'` e vencer o `SIGNUP_MODE` do `.env`. |
| `20260923120000` | `0385_memoria_da_org_aceita_origem_agente` | **A memória da organização aceita a origem `agent` (diagnóstico de @vgamkt, #1130).** A ferramenta MCP `crm_save_org_memory` grava `org_memory_entries.source = 'agent'`, mas o CHECK inline da 0067 (`org_memory_entries_source_check`) só aceitava `manual` e `flywheel`: toda chamada falhava com 23514. O CHECK passa a aceitar `('manual','flywheel','agent')` — ampliar, e não regravar como `manual`, porque a origem é a trilha de quem escreveu a política da empresa (decisão do titular). **Dado tocado:** nenhum; as linhas existentes cabem no conjunto novo. Idempotente (`drop if exists` + `add`); bloco único no apêndice do baseline. |
| `20260923120100` | `0386_preco_catalogo_e_conta_openai` | **As duas tabelas de preço do OpenAI passam a dizer o que a fonte mediu, juntas.** O `pricing.ts` — que grava `llm_calls.cost_cents` e é o que a tela Uso e orçamento soma — cobrava `gpt-5.6-sol` a 400/2000 (preço PROMOCIONAL medido na fonte oficial em 2026-09-23, `developers.openai.com/api/docs/pricing`, faixa Standard; a própria página declara a promoção válida ao menos até 21/11/2026), enquanto `ai_models` e `ai_pricing` seguiam com 500/3000, a versão não promocional do catálogo 0101: a tela anunciava um preço, a conta somava outro e os dois divergiam do que a API cobra. As DUAS tabelas mudam no mesmo comando de propósito — o invariante `catalogo-de-modelos` ("o preço da tela é o MESMO que a conta usa") reprova qualquer lado que ande sozinho, e a `notes` mantém o prefixo `catálogo` que o invariante de procedência exige (preço de apêndice, não de cura automática do backfill 0113). Entram os três ids OpenAI que o código já cobrava e a tabela não tinha (`gpt-4o`, `gpt-4o-mini`, `gpt-4o-2024-05-13`) — sem linha, `computeCost()` cai no catálogo/backfill e o número pode não ser o do código, o mesmo buraco da #1478 por outro caminho. `notes` grava FONTE e DATA da medição porque preço promocional muda. Apêndice idempotente no `baseline.sql` logo depois do bloco da 0104. Gates: `tests/unit/preco-openai-codigo-e-tabela-concordam.test.ts` (compara as DUAS fontes — código × tabela — nos 12 ids, com sabotagem medida) e o invariante `catalogo-de-modelos` no CI. |
| `20260922164700` | `0387_canal_datafy` | **Canal de WhatsApp Datafy — parceiro homologado pela Meta que espelha a Cloud API — como canal OPCIONAL DA INSTALAÇÃO, desligado por padrão (decisão do dono, doc 54, opção b).** Recorte do PR #1130, de @vgamkt (`0235` / `20260913150000` na branch dele, renumerada aqui em número e timestamp). `channel_sessions` ganha `datafy_phone_number_id` (o `sessionRef` do canal, coluna PRÓPRIA e não a do canal oficial, porque os dois provedores podem conviver na mesma instalação e endereçam servidores diferentes), `datafy_waba_id` e `datafy_token_encrypted` (cifrado por `fn_encrypt_oauth`, como a chave do outro parceiro). Três CHECKs recriados por alargamento puro — `channel_sessions_provider_check` e `channel_sessions_provider_ref_check` com o vocábulo `datafy`, e `webhook_events_log_provider_check` (0151), sem o qual a rota genérica de canal não conseguiria arquivar o corpo cru do que o provedor manda. Índice único parcial entre ativos para `datafy_phone_number_id` (desenho da 0165, issue #236), com dedup `-conflito-<id>` ANTES dele. O desligamento é do CÓDIGO (`DATAFY_ENABLED`), não do schema: as colunas nascem nullable e vazias em toda instalação. Sem tabela nova (a tabela tocada já tem RLS por organização), sem função nova em `public`. No baseline, colunas e vocabulário nos blocos ÚNICOS das constraints; o apêndice próprio só traz a dedup e o índice. |
| `20260923160000` | `0392_demanda_derivada_nao_reduplica` | **O `update.sh` do clone duplicava a demanda que já existia.** O backfill histórico da 0136 (R2: toda conversa que nunca escalou vira demanda `origem='derivada'`) vive no apêndice do `baseline.sql`, que o kit re-aplica INTEIRO a cada atualização. O guard de R2 só procurava outra `'derivada'` com o mesmo `aberta_em` e não enxergava a demanda `'inbound'` que o trigger da 0138 abre na entrada. Medido em pg17 com os blocos do baseline da main de 23/09: `inbound=1` depois do install + 1 mensagem, `inbound=1 derivada=1` depois do update. Sintoma mudo: o Radar publica o dobro de demandas abertas sem próximo passo e `fn_atrito_metrics` devolve `escopo.demandas` dobrado. **Dois artefatos:** o guard novo (`not exists` em `demanda_conversas` para a conversa) mora no apêndice do baseline, onde R2 re-executa; esta migration faz só a AUTO-CURA, e o mesmo `delete` está no apêndice para o clone do kit. A cura só apaga a `'derivada'` intocada (sem próximo passo, lead, dono humano ou caso), ligada a uma conversa só e nascida DEPOIS de outra demanda de origem real naquela conversa — a derivada legítima do backfill é anterior ao trigger e fica, inclusive numa conversa reaberta que ganhou uma `'inbound'` depois. **E não apaga a duplicata que já é referência** (`conversations.current_demanda_id`, `messages.demanda_id`, `lead_checkpoints.demanda_id`): o passo 2 do backfill da 0222 escolhe a vigente pelo maior `aberta_em`, e a `'inbound'` carrega o `sent_at` do WAHA (anterior ao insert da conversa), então a duplicata costuma virar a vigente; apagá-la zeraria as referências por `on delete set null`, abriria outra demanda na próxima entrada e cancelaria como vencido o acompanhamento com fronteira nela. Como essas colunas nascem no bloco da 0222, mais abaixo no apêndice, a cura no baseline roda dentro de um `do` que só age quando as três existem (no install ainda não existem, e não há demanda para curar). Guardado por `tests/invariants/atualizacao-nao-reduplica-demanda.test.ts`, que EXECUTA o bloco extraído do baseline. Retoma o trabalho de agosto que usava o número 0142, hoje ocupado. |
| `20260923150000` | `0391_anonimizar_pela_tela_redige_conversas` | **Anonimizar o contato pela TELA passa a redigir mensagens, conversa e o resumo do agente (achado da prova prática do PR #1130).** Havia dois caminhos de anonimização com cobertura diferente: o pedido formal (`fn_lgpd_cascade_redact_contact`) redigia `messages` e `conversations`; o botão da ficha (`fn_lgpd_anonymize_contact` + `lib/lgpd/cascata.ts`) só reescrevia o contato, leads, atividades e a régua — o nome e o CPF que a pessoa escreveu ficavam no corpo das mensagens, no `last_message_preview` (cabeçalho da ficha anonimizada) e na mídia no bucket. `lead_checkpoints` (resumo corrido, compromissos, objeções, próxima ação e declaração do turno — texto de modelo que nomeia a pessoa) não estava em NENHUM dos dois. Conserto no ESTADO: gatilho `trg_redigir_conversas_ao_anonimizar` (`after update of is_anonymized`, só na virada false→true), o mesmo desenho dos outros gatilhos de redação do schema; os comandos de mensagens/conversas são os MESMOS da cascata formal, que os repete sem efeito novo. A mídia entra em `storage_redaction_queue` ANTES de a coluna ser zerada (`request_id` nulo; fila idempotente por `(bucket, object_path)`). **Dado tocado:** cura de contatos JÁ anonimizados — redige mensagens, conversas e checkpoints deles e enfileira a mídia; cada comando só alcança linha com resíduo, então reaplicar não reescreve nada. Função nova `security definer` em `public` com as DUAS origens de EXECUTE revogadas (public e anon, e authenticated). Gate: `tests/invariants/lgpd-anonimizar-pela-tela-redige-conversas.test.ts` (os dois caminhos, controle positivo e o vizinho intocado). |
| `20260923210000` | `0394_fluxos_de_atendimento_base` | **A base dos fluxos de atendimento (roteiro de perguntas que a IA conduz no turno), de @vgamkt (#1130), como MÓDULO OPCIONAL desligado por padrão (doc 64, opção a).** A chave é `MODULO_FLUXOS_DE_ATENDIMENTO` em `platform_config` (0341) — sem migration para ela. Das oito migrations do autor (0236–0243 na branch dele) nenhuma tabela sobra, pela DIRC: a resposta do cliente vai para `contacts.custom_fields` (já exportada ao titular e já zerada pela anonimização nos dois caminhos, `trg_contacts_anonimizado_limpa_custom_fields`); a trilha vai para `followup_enrollment_events` SEM o valor; as tentativas são contadas da trilha; a síntese é montada dos campos (sem `completion_note`, sem job `flow_summary`). Schema: `followup_flow_pointers.surface` aceita `atendimento` e `followup_enrollments.status` ganha `coletando` (blocos únicos da 0196 e da 0145 editados no lugar; o laço da 0145 passa a derrubar pelo catálogo a versão sem `coletando`); `coletando` fica no grupo sem relógio e FORA de `idx_followup_enrollments_one_live` — na prova prática, o roteiro parado ocupava a vaga do follow-up e quem sumia no meio nunca recebia a retomada; índice único próprio `idx_followup_enrollments_um_roteiro_coletando` (um roteiro vivo por contato); `ai_router_members.flow_pointer_id` com FK COMPOSTA `(organization_id, flow_pointer_id)` → `followup_flow_pointers(organization_id, id)` `on delete set null (flow_pointer_id)` (pg15+, o piso do kit), com o índice único `(organization_id, id)` que ela exige; gatilho `trg_contato_anonimizado_encerra_roteiro` (virada false→true de `is_anonymized`) cancela o `coletando` do contato nos dois caminhos de anonimização; gatilho `trg_enrollment_superficie_coerente` (BEFORE INSERT/UPDATE de `status`/`pointer_id`) recusa com `23514` enrollment de roteiro fora de `coletando`/terminal e `coletando` fora de roteiro — os produtores do relógio escolhem pointer por `trigger_config`, não por superfície. Revisão adversarial: a superfície fica IMUTÁVEL (`trg_superficie_do_fluxo_imutavel`, 23514) e roteiro só tem gatilho manual (CHECK `followup_flow_pointers_roteiro_so_manual`) — sem isso um viewer mudava pelo PostgREST a superfície de um fluxo de silêncio e a varredura abortava a cada tick; e o bloco da fusão por nono dígito (0198) no baseline, que roda a cada `update.sh`, passa a encerrar o roteiro vivo excedente (fica o mais novo, evento `roteiro_cancelado`) ANTES de reapontar `followup_enrollments`, senão o índice novo daria 23505. Funções novas com as origens de EXECUTE revogadas. Sem dado reescrito. Plano: `docs/superpowers/plans/2026-09-23-fluxos-de-atendimento.md`. |
| `20260923230000` | `0397_roteiro_encerra_com_humano_e_prazo` | **O roteiro de atendimento encerra quando um humano assume, no opt-out e no prazo (PR 2 do port do #1130, de @vgamkt; achados 9 e 5 da prova prática).** Gatilho `trg_contato_encerra_roteiro_com_humano_ou_opt_out` na virada false→true de `contacts.force_human` ou `contacts.is_blocked`: o `coletando` do contato vira `cancelled` com evento `roteiro_cancelado` (`humano_assumiu`/`opt_out`). Um lugar para todos os escritores dessas colunas (passagem do motor, ferramenta de handoff, orquestrador, teto de orçamento, ingestão do canal) — remendar cada um era a classe que o autor pagou cinco vezes. Encerrar, não pausar: retomar as perguntas depois de uma pessoa atender fala do que ela já tratou. A pausa curta por resposta manual pelo celular (`bot_silenced_until` com prazo) NÃO encerra: o roteiro só não roda com a IA calada. `fn_encerrar_roteiros_vencidos(p_limite)` (service_role) encerra o `coletando` sem mensagem lida há mais de `settings.expira_em_horas` do grafo (padrão 72 h), em lotes com `skip locked`, com evento `roteiro_expirado`; chamada pelo relógio do follow-up (`lib/relogio/executar.ts` e o cron `followup-flow-worker`). Funções novas com as origens de EXECUTE revogadas. Sem dado reescrito. Número 0397 e carimbo conferidos livres na main e nos PRs abertos (0393 #1527, 0396 #1561). |
| `20260924053717` | `0401_conversoes_processamento_e_reenvio` | API Google por conexão, protocolo de processamento, proteção de envio concluído e reprocessamento exclusivo de conversão. |
| `20260924053718` | `0402_google_captura_e_qualificacao` | Captura gbraid/wbraid, etapa de qualificação opcional com FK da mesma organização, snapshot da ação/data e reprocessamento por evento. |
| `20260923140000` | `0390_fotos_no_catalogo` | **O produto ganha foto, e o agente de IA manda a foto junto (ideia de @vgamkt, a partir do #1130; doc 62, opção c).** `catalog_products.fotos text[] not null default '{}'` com CHECK `cardinality(fotos) <= 5`: guarda CAMINHOS de Storage (`<org>/<produto>/<uuid>.<jpg\|png>`), na ordem da tela, e a primeira é a capa. **Coluna e não tabela:** pôr, tirar e reordenar é UM update da linha, e RLS (leitura da org, escrita `manager`+ da 0204) e cascata vêm da linha do produto. Bucket NOVO `catalog-photos`, **privado**, 5 MB, só `image/jpeg`/`image/png` (teto e formatos de imagem do WhatsApp oficial), sem policy em `storage.objects` — só o `service_role` escreve e lê, depois de a rota conferir o papel. Separado de `whatsapp-media` porque a limpeza de LGPD apaga a mídia das conversas: quando o agente manda a foto, o app a COPIA para a pasta da conversa, e é a cópia que a conversa possui. Quem LÊ um caminho da coluna (tela que assina, agente que copia, rota que apaga) confere o prefixo `<org>/<produto>/` — a coluna é gravável pelo PostgREST e a leitura é por service role. Aditiva e idempotente (`add column if not exists`, constraint recriada, bucket com `on conflict do update`); `imagem_url` (0204) fica intocada. |
| `20260924200200` | `0408_prospeccao_vence_no_cron` | **Os candidatos da prospecção nativa ganham prazo, e o cron `data-retention` é quem apaga (issue #1313, fechamento de verdade).** A declaração sozinha não expunha nada: nenhum código apagava `prospecting_candidates` por idade, e a razão escrita da isenção no teste de guarda apontava um poda de admin client que não existe. Função nova `fn_expurgar_prospeccao_vencida(p_retencao_dias, p_limite)` (`security definer`, as DUAS origens de EXECUTE revogadas, grant só a `service_role`), padrão **365 / piso 90** dentro do corpo (`greatest(coalesce(...))`), decisão do dono em 24/09 no PR #1577 alinhada ao horizonte da conversa do caso (0281) e da captação. Relógio `coalesce(attempted_at, created_at)` — nunca contatado conta da criação (o caso da issue: campanha montada e abandonada), contatado conta da última tentativa (`lib/prospecting/worker.ts` grava antes do envio), para linha antiga com contato recente não cair no meio do funil. Duas guardas que não são idade: `status not in ('queued','sending')` (trabalho vivo nunca entra, em nenhuma idade) e `suppression_salt is null` (o tombstone de LGPD da 0370 sobrevive para sempre — expurgá-lo reabriria a porta que a anonimização fechou e o trigger `prospecting_refuse_erased` é quem barra a reimportação). Índice parcial em EXPRESSÃO `prospecting_candidates_expira_idx` no predicado do expurgo (desenho da 0174): sem ele a poda diária varre a tabela inteira desde a primeira instalação que cria campanha. O cron ganha a oitava `drenar()` com os campos de relatório e o predicado em `houveEfeito` NO MESMO commit (a lição da quarta e da quinta poda: esquecer o predicado é mudo — a rodada apaga e não deixa registro); `PROSPECCAO_RETENTION_DAYS` em `lib/env.ts` no mesmo formato `z.string()` das cinco anteriores. `PROSPECCAO` sai de `SEM_FUNCAO_NO_SQL` e entra em `DONO_NO_SQL` no teste de guarda. Gate `tests/invariants/retencao-prospeccao-expurgo.test.ts` com a matriz de 7 casos da issue (o mais importante: o tombstone com `suppression_*` permanece mesmo muito antigo e continua barrando a reimportação). Fragmento de release atualizado (publicado na seção 1.48.0 do `CHANGELOG.md`). Número 0408 e carimbo 20260924200200 alocados pela triagem (0401–0407 com outros PRs em voo). |
| `20260923220000` | `0396_conversa_fica_com_quem_atendeu` | **"A conversa fica com quem atendeu" vira ajuste por empresa, DESLIGADO por padrão (ideia de @gustavorodcruz96, #1527; recorte da 0393 daquele PR, que não se reaproveita).** `fn_service_inbound` só reabre a conversa encerrada com o último atendente (`claimed`, IA calada, demanda nova com dono humano, `assigned_at` no instante da reabertura para o prazo de devolução automática contar do episódio novo) quando `organizations.settings.routing.conversation_stays_with_attendant` é o booleano `true` e o responsável ainda é membro agent+ da organização. Desligado ou ausente — o estado de toda empresa que já existe —, a função faz o mesmo que antes: reabre como `open`, sem dono, e o roteamento segue. A revisão do atendimento continua nova. Sem mudança de tabela ou backfill; função idempotente no baseline e nesta migration. |
| `20260924070000` | `0403_lead_so_liga_a_propria_empresa` | **O lead só se liga a contato e responsável da própria empresa, no banco (revisão adversarial do #1586).** As FKs de `crm_leads.contact_id` e `owner_user_id` não perguntam de qual organização; os handlers já conferiam, mas a REST do banco (`/rest/v1/crm_leads`, GRANT para `authenticated`, políticas que não olham `contact_id`) e a RPC `fn_nascer_lead_da_conversa` (security invoker) gravavam o vínculo cruzado. **Cura antes do gatilho**, genérica e idempotente: lead com contato de outra organização perde o contato; lead com responsável que NUNCA foi membro da organização perde o responsável (e o `owner_kind`); cada um ganha uma atividade `lead_edited` de sistema com o porquê, sem o id alheio. Responsável desligado ou viewer não é curado — o lead era dele. **Gatilho `trg_lead_so_liga_a_propria_empresa`** (BEFORE INSERT OR UPDATE OF contact_id, owner_user_id, organization_id; função security definer, search_path fixo, EXECUTE revogado de public/anon/authenticated): no INSERT confere o que vier; no UPDATE só o campo que mudou. Responsável = vínculo não revogado e papel acima de viewer (G3-04). Erro genérico `PT404`/`PT422`, que o PostgREST devolve como 404/422 sem dizer se o id existe noutra organização. Sem índice novo: as duas consultas usam a PK de `contacts` e o unique `(user_id, organization_id)`. |
| `20260924060000` | `0400_moeda_do_negocio_da_conversa` | **O negócio que nasce de uma mensagem nasce na moeda da organização.** `fn_nascer_lead_da_conversa` não passava `currency` e o insert pegava o default `'BRL'` em toda organização (medido: 229 de 229 negócios em BRL numa organização em guarani), e o valor do pedido sairia para a plataforma de anúncio lido em real. Alinha à moeda da organização os negócios SEM valor que nasceram em BRL numa organização de outra moeda; os que têm valor ficam como estão. |
| `20260924180000` | `0404_comando_da_conversa_sem_reavaliar_rls` | **As abas da Inbox param de reavaliar a RLS de `contacts` duas vezes por conversa — contagem de 1,8 a 2,2 s cada numa instalação com 528 conversas (issue #1571).** `comando_da_conversa(conversations)` nasceu INVOKER na 0203 de propósito ("respeita RLS de quem pergunta" — decisão certa e mantida no desenho); o custo só apareceu em instalação grande: as duas subconsultas em `contacts` rodam com o papel de quem chamam e reavaliavam `tenant_isolation_contacts_all` (`fn_user_org_ids()`/`fn_is_platform_admin()`, SECURITY DEFINER não-inlináveis) DUAS vezes por linha, além da policy da própria linha. Medido na issue (PostgreSQL 17.6): `authenticated` 823–846 ms → `postgres` 25 ms → `authenticated` com a função DEFINER 75–94 ms. **Por que não enfraquece a RLS:** a função recebe a LINHA da conversa, que só chega ao chamador depois da RLS de `conversations`; lê só `force_human`/`is_blocked` do contato DESSA linha, com `ct.organization_id = $1.organization_id` nas duas subconsultas (a policy de UPDATE de `conversations` não confere `contact_id` e a FK não passa pela RLS, então sem o predicado uma conversa apontada para contato de outra empresa leria os dois bits dele), e devolve apenas o texto do comando (abas conferidas antes e depois no papel `authenticated`: idênticas). **O parâmetro virou SEM NOME** — a PostgREST expõe coluna calculada namedada em `/rpc` (docs v10/v12: "unnamed parameter … prevent it from being exposed as an RPC"), e sob `definer` aquele endpoint aceitaria uma linha fabricada e devolveria `force_human`/`is_blocked` de qualquer `contact_id` cross-tenant; sem nome, `?select=`/`?filtro=` seguem iguais e o `/rpc` some (por isso o `notify pgrst`: forma de schema mudou). DROP + CREATE (o Postgres recusa `create or replace` trocando nome de parâmetro) sem `cascade` — medido que nada em `supabase/` depende da função; grants das DUAS origens refeitos no mesmo arquivo. Idempotente no `baseline.sql` pelo APÊNDICE, antes da varredura anon (a definição do meio segue como a 0203 a escreveu; a anotação de doutrina ao lado dela aponta para cá), e a varredura anon do baseline passou a alcançá-la preservando os grants. Número 0404 e carimbo 20260924180000 conferidos livres (main até 0400; PRs abertos 0401×3, 0402×2, 0403). |
| `20260924190000` | `0405_revogacao_de_membro_desatribui_conversas` | **Ao revogar um membro, as conversas abertas dele voltam para a fila (#1562).** `fn_routing_member_revoked` não desatribuía as conversas abertas de quem era revogado da organização: uma conversa assumida ficava com `assigned_to_user_id` apontando para o usuário revogado e `bot_silenced_until = 'infinity'`, deixando a conversa muda sem que nenhum atendente a visse como sua nem a IA respondesse. Agora, a função desatribui todas as conversas abertas (`status in ('open','pending','claimed','ai_handling')`), limpando `assigned_to_user_id`, `assigned_to_user_name`, `assignee_kind`, resetando status para `open`, soltando o silêncio do bot com a regra do release de `fn_conversation_assign` (`bot_silenced_until = null` só quando não há `last_handoff_at`; conversa que a IA passou a um humano continua na fila humana), registrando evento em `conversation_assignment_events` (`reason = 'member_revoked'`) e acordando o roteamento por canal (`fn_wake_channel_routing`). Amplia o CHECK inline `conversation_assignment_events_reason_check` com `member_revoked` (drop if exists + add, molde da 0385; as linhas existentes cabem no conjunto novo): sem isso, toda revogação de membro com conversa aberta falhava com 23514. |
| `20260924200000` | `0406_logo_por_tema` | Logo escuro opcional por instalação/organização; preserva o padrão e o isolamento dos arquivos. Numerada `0401` na branch de origem (PR #1588, de @vitorlacerdadigital); renumerada na triagem porque a `0401` ficou com o #1566. |
| `20260924200100` | `0407_social_identity_na_fusao` | **A junção de fichas leva o `social_identity` do contato que sai (issue #1455).** `fn_mesclar_contatos` já herdava `name`, `display_name`, `birthdate`, `email`, `phone_number` e a origem do `waha_lid` da ficha que sai — a identidade social ficou de fora. Com o #1444 o `upsertSocialContact` (`lib/channels/zernio/ingest.ts`) filtra `is_merged_into is null`, do mesmo jeito que `fn_upsert_wa_contact` faz no WhatsApp: sem herdar, a próxima DM de quem veio por Instagram não acha ficha viva com aquela identidade e cria uma nova, refazendo a duplicata que a fusão acabou de desfazer. Mesmo desenho do `waha_lid`: o perdedor entrega o `social_identity` quando o vencedor não tem um, com guarda de unicidade contra um TERCEIRO contato vivo (índice parcial `contacts_org_social_identity_unique` — a lápide já tirou os perdedores da disputa), e o `update` usa `coalesce` para não sobrescrever a identidade que o vencedor já tem. Redefine a função inteira a partir da definição vigente (`0262_cliente_pela_agenda.sql`), repetindo o par `revoke`/`grant`; sem coluna nova, sem dado reescrito, sem policy. Apêndice idempotente no `supabase/baseline.sql`, antes da varredura de `anon` (a cerca `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts` reprova função criada depois dela). **Número e carimbo:** `0407`/`20260924200100`, alocados pelo coordenador da fila de PRs de 24/09. O PR chegou com `0401`/`20260924120000`, o próximo livre na `main` quando foi aberto, mas `0401` também era pedido por PRs abertos (#1566, que fica com ele, e #1588); a renumeração foi feita pelo mantenedor, como o template do PR combina. Gate: `tests/unit/fusao-de-contato-herda-a-identidade-social.test.ts`. |
| `20260925000000` | `0409_knowledge_content_hash` | **A base de conhecimento só reindexa o que mudou (de @vgamkt, #1130; numerada 0249 na branch de origem).** `ai_knowledge_sources.content_hash` guarda o hash do conteúdo efetivamente indexado; o `rag-indexer` pula a fonte cujo hash é igual, que está `success` e cuja versão ativa foi calculada com o mesmo modelo de embedding. É o que torna barato o "Preparar tudo de novo" (`POST /api/v1/ai/knowledge/reindex-all`). Coluna nullable, sem backfill: a primeira indexação depois do update grava o hash. Sem função nova. |
| `20260925120000` | `0410_catalogo_requesty` | **Catálogo da Requesty.** Roteador OpenAI-compatível como a OpenRouter, entra pelo vocabulário aberto da 0127 e pela mesma fábrica `@ai-sdk/openai` com `.chat()`. Cinco modelos com ids e preços verificados em `GET https://router.requesty.ai/v1/models` (convertidos para centavos por milhão), com `supports_vision` porque num roteador é o catálogo que decide visão. **Não insere em `ai_pricing`, mas a linha aparece:** o backfill 0113 do `baseline.sql` a deriva de `ai_models` no próximo `update.sh`. `ai_pricing` e `precoDoCatalogo` resolvem só por `model_id`, sem provider, então o mesmo id (`openai/gpt-4o-mini` etc.) na OpenRouter e na Requesty divide o preço com ou sem essa linha. Sem `is_default_for_provider`. Idempotente (`on conflict do update`), sem coluna nem função nova. |
| `20260925150000` | `0412_birthdate_na_proposta_de_dado` | **O agente passa a poder propor a DATA DE NASCIMENTO do contato, pelo mesmo fluxo de proposta com aprovação humana que já existe para nome, e-mail e telefone (issue #1546 — clínica de nutrição que quer mandar parabéns e disparar por aniversário).** A coluna `contacts.birthdate` existe desde cedo e a rota de PATCH já a aceita; o que faltava era a fila. O vocabulário fechado de `contact_field_proposals.campo` (0123, três vocábulos de propósito: o que entra aqui vira escrita em `contacts`) ganha `birthdate` — ALARGAMENTO do conjunto, não abertura de porta, e a outra metade da fronteira é `CAMPOS_PROPONIVEIS` em `lib/contacts/proposta-de-dado.ts`, que nasce igual. Sem o vocábulo, a proposta passava pela validação do código e morria em 23514 na confirmação: modo de falha silencioso, com a IA ouvindo "não consegui registrar" para um dado que o cliente repete com naturalidade. `valorAceitavel` valida a MESMA forma que `contactPatchSchema` exige (`AAAA-MM-DD`) mais o calendário de verdade (`1990-02-30` tem a forma e não existe) e a data não futura — o cron `contact-birthdays` só aciona o que é data de nascimento. **Dado tocado:** nenhum; as linhas existentes da fila cabem no conjunto novo. Idempotente (`drop if exists` + `add` — o nome existe desde a 0123 e um `add` solto reprovaria em 42710 no clone que segue a cadeia). No `baseline.sql`, o vocábulo entrou por EDIÇÃO NO LUGAR do bloco ÚNICO da constraint, e não por um bloco novo no apêndice: `baseline-constraint-reconstruida` reprova `add constraint` repetido, porque o bloco antigo falharia no `update.sh` de um clone cuja fila já tenha uma proposta de nascimento e a tabela ficaria sem constraint entre o `drop` e o `add` que funciona. Nenhuma função nova em `public` ⇒ o item 9 da doutrina não é acionado. |
| `20260925170000` | `0414_redact_unificado_chama_a_cascata` | **Os DOIS caminhos de anonimizar passam a redigir na MESMA função (issue #1504, acompanhamento do #1501).** O #1501 consertou o sintoma pelo gatilho, mas a causa ficou: `fn_lgpd_cascade_redact_contact` (pedido formal, `lib/lgpd/redact-cascade.ts`) e `fn_lgpd_anonymize_contact` (botão da ficha) eram duas redações com conjuntos diferentes, e o botão reescrevia o CONTATO e mais nada — as 11 tabelas da issue (`orders`, `sales`, `voice_calls`, `prospecting_candidates`, `agent_cases`, `agent_case_events`, `demandas`, `agent_inbox_items`, `agent_case_chat_messages`, `passagens_de_atendimento`, `entregas_de_aviso_de_caso`) e `contacts.consent`/`source_metadata`/`tags` só eram alcançadas por quem abria pedido formal. **O que muda:** o portão do botão CHAMA a cascata canônica (`perform public.fn_lgpd_cascade_redact_contact(org, contato, null)`) e para de escrever por conta própria — o `update contacts` sai do corpo dele. O portão continua portão: autoridade (suporte, papel `admin`, platform_admin e MFA comprovado, tudo antes da escrita), mutex (`fn_service_lock` antes do `for update`, ordem da 0229) e o contrato `{already_anonymized, anonymized_at}` com a data ORIGINAL na retomada (a igualdade exata que `lgpd-agenda-lock-order` cobra na segunda chamada). `p_request_id` nulo no caminho do botão: não há pedido de titular, e fila (idempotente por `(bucket, object_path)`) e auditoria já aceitam nulo — mesmo formato da 0391. **Rótulo único:** o contato passa a ficar com `Cliente Anonimizado #<8>`, o que o pedido formal já gravava; a cópia do diálogo (`AnonymizeDialog.tsx` + dicionário + zh-CN) acompanha. **Dado tocado:** nenhum reescrito por esta migration — o efeito novo é nos contatos anonimizados A PARTIR dela. **Sem coluna, sem constraint, sem tabela nova; a função `fn_lgpd_anonymize_contact` é redefinida (CREATE OR REPLACE com assinatura igual).** No `baseline.sql`, o mesmo corpo vai no APÊNDICE, antes da VARREDURA anon, espelho da cadeia (`apendice-do-baseline-nao-diverge-da-cadeia`). **Gates:** `tests/invariants/lgpd-redact-unificado-alcanca-pelo-catalogo.test.ts` (catálogo: TODA tabela com FK para `contacts` tem decisão escrita de redigir/manter, as 11 da issue na cascata, os dois campos zerados e a chamada única) e `tests/unit/redact-unificado-os-dois-caminhos-chamam-a-mesma-funcao.test.ts`. |
| `20260925163550` | `0413_provedor_personalizado` | **Provedor personalizado compatível com OpenAI (#1642).** A tela de Credenciais oferece um endpoint do próprio operador (OmniRouter, 9Router, LiteLLM hospedado, proxy corporativo), num endereço público: a base URL passa pela régua de destino de organização (decisão 22-d) no teste, na validação e em cada chamada do agente. `ai_provider_credentials.base_url text` nullable com CHECK `^https?://` — o ENDEREÇO mora na LINHA da credencial, e não em `.env` nem no binding do ponto, porque cadastro, validação e turno do agente têm de ler a mesma escolha. A chave continua cifrada pelo caminho da 0023 (nenhuma coluna de segredo nova). View `ai_provider_credentials_safe` recriada com a coluna no FIM (`create or replace` não reordena) e o grant POR COLUNA da 0150 reemitido com `base_url` — sem ele a view nova responderia `permission denied` para todo manager e a tela viraria `[]`. **Nenhum provedor nativo muda:** a coluna nasce `null`, o resolvedor só a lê quando o provider é `custom` (query separada, para um clone sem a migration não derrubar os quatro) e o registry recusa a chamada sem endereço em vez de cair no endpoint da OpenAI. Teste de conectividade ANTES de salvar (`POST /api/v1/ai/credentials/test`, `GET {base}/models`, timeout 10s) com o mesmo validador que roda em segundo plano sobre a linha gravada. Aditiva e idempotente; sem função nova. Apêndice no `baseline.sql` antes da varredura de anon. |
| `20260926030000` | `0421_jev_observacoes` | **As observações do Jev, tarefa a tarefa (onda 2, bloco 2.1).** Toda tarefa nova do Jev nasce observando: ele opina, o mecanismo de hoje decide, e o cartão mostra a concordância. `public.jev_observacoes` guarda, por resposta do Jev, a tarefa, o estado dela (`observando`/`decidindo`, CHECK), o rótulo, a probabilidade e a confiança do Jev, o rótulo do mecanismo de hoje (`NULL` = ele não decidiu) e `concordou` GERADA (`NULL` sem par), mais `conversation_id`/`message_id`/`job_id` SEM FK (a linha não guarda nada da pessoa e não pode travar a anonimização nem cair com o histórico). Tabela e não `llm_calls`: lá não há coluna para rótulo nem probabilidade, e uma chamada com várias perguntas é uma linha para N tarefas. RLS: só SELECT por membro (`tenant_isolation_jev_observacoes_select`); a escrita é do servidor. Índices (org, tarefa, created_at desc) para o cartão, (created_at) para a poda e ÚNICO parcial (org, tarefa, message_id) `where message_id is not null` — o retry do job pergunta de novo sobre a mesma mensagem, e a segunda resposta contaria em dobro (o gravador faz `on conflict do nothing`). `fn_expurgar_observacoes_do_jev(p_retencao_dias, p_limite)` — `security definer`, padrão 90 / piso 30 no CORPO (a janela da concordância), revogada de `public`, `anon` e `authenticated`, só `service_role` — chamada em lotes pelo cron `data-retention` (`JEV_OBSERVACOES_RETENTION_DAYS`). Aditiva e idempotente. Apêndice no `baseline.sql` antes da varredura de anon (a função nova nasce antes dela) e, com isso, antes das proteções e travas do fim. |
| `20260925200000` | `0416_autoria_em_nome_de_na_mensagem` | **Quem enviou por token passa a ser conhecido (issue #1613, item C — "em nome de").** A única porta por token que envia mensagem era também a única que não registrava quem decidiu o envio: o token é da organização, a linha nascia `sent_by_user_id` nulo e o balão lia "Sistema" — a conversa perdia que foi uma pessoa no ERP/cobrança/agenda que mandou. `messages.sent_on_behalf_of_user_id` (uuid nullable, SEM backfill e sem FK, como `sent_by_user_id`) guarda a pessoa; os dois nomes vão em `metadata.sent_on_behalf` porque o balão não faz join. **O gate é duplo e vem do FORA do corpo:** o escopo novo `messages:on_behalf` na LINHA DO TOKEN (`resolveAuthDual` passa a devolver `scopes`) e o membership — `user_organizations` com `revoked_at is null` e papel `agent+` pela régua `roleAtLeast` —, com a organização vinda da linha do token; sem escopo, com sessão de navegador, usuário de outra org, revogado ou `viewer`, 403 antes de qualquer escrita. O handler recusa alto se `on_behalf_of_user_id` chegar pelo input sem o ctx que só a rota preenche (a mesma entrada atravessa o MCP — falha fechada). Auditoria com OS DOIS atores: `actor_api_token_id` = o token que apertou, e a pessoa em `metadata.on_behalf_of_user_id` (`actor_user_id` segue nulo, como em todo envio por token: a coluna registra quem autenticou). **Dado tocado:** nenhum reescrito; a coluna nasce `null` em toda linha existente. **Sem função, sem constraint, sem policy nova** ⇒ o item 9 da doutrina não é acionado; apêndice idempotente no `baseline.sql`, logo depois do bloco da 0415 e antes da varredura anon. |
| `20260925180000` | `0415_teto_de_tokens_ativos_por_organizacao` | **O teto de tokens ATIVOS por organização passa a ser do BANCO (issue #1448 — a issue mesma diz: "mitigar não é fechar").** `api_tokens` não tinha CHECK, trigger nem índice que limitasse QUANTOS tokens vivos uma organização mantém, então o teto por token da Spec 11 §7 (60/min) era multiplicável por quem já estava dentro — e o teto de ESCRITA (30/min, o que protege o número de WhatsApp) não tem agregado nenhum, enquanto o 600/min por organização do PR #1446 segura só a leitura. **Gatilho `trg_teto_de_tokens_ativos`** (BEFORE INSERT, função `fn_teto_de_tokens_ativos` `security definer` com `search_path` fixo e `revoke execute` das duas origens, molde da 0403): conta os tokens da organização que estão VIVOS — `revoked_at is null` e (`expires_at is null` ou ainda não vencido) — e recusa quando `>= 50`. **Contar só o vivo é o que preserva a rotação legítima:** revogar o antigo e emitir o novo passa sempre, e revogado/expirado libera espaço na hora. **`50` é o número escolhido e está em UM lugar só** (o corpo da função, na migration e no apêndice do baseline): ninguém mantém cinquenta integrações, e mesmo assim a mudança de produto futura é mexer uma constante — o limite chega ao usuário pela mensagem do próprio erro, que é quem o sabe. **Quem já está acima não é tocado:** o gatilho recusa só inserção, então instalação com mais de 50 tokens hoje segue funcionando e não emite mais nenhum (pergunta 3 da issue). **Os tokens efêmeros do runtime contam** de propósito — há marcador forjável por `prefix`/`name` (a policy de `api_tokens` é `for all` para admin, então PostgREST direto escreve qualquer coluna) e um teto com exceção seria teto com porta; com TTL de 300 s e revogação no fim do run, eles mal aparecem na contagem. **Erro próprio:** `raise exception` com SQLSTATE `PT409` (desenho do `PT404`/`PT422` da 0403) e mensagem PT-BR dizendo o limite e mandando **revogar um token não utilizado** para liberar espaço; a rota `POST /api/v1/settings/api-tokens` reconhece o código e devolve `409 api_token_teto_atingido` com a mensagem do banco — em vez do 500 genérico que devolveria —, e o `onError: showApiError` do `useCreateApiToken` já faz o toast chegar à tela. Código declarado em `lib/api/errors.ts`. Sem coluna, sem backfill, sem policy nova; apêndice idempotente no `baseline.sql` ANTES da varredura anon. Gates: `tests/unit/teto-de-tokens-ativos-da-organizacao.test.ts` (a tripla migration × apêndice × MANIFEST, o predicado que ignora revogados e expirados, o `>=` que recusa no teto+1, os revokes, o mapeamento `PT409 → 409` do emissor e o código declarado) e `tests/invariants/teto-de-tokens-ativos-da-organizacao.test.ts` (Postgres real: dentro do teto passa, no teto+1 recusa com a mensagem, revogado libera espaço, e a instalação acima do teto não é derrubada). |
| `20260925235500` | `0418_publicar_com_provedor_personalizado` | **Agente com o provedor personalizado passa a PUBLICAR (acompanhamento da 0413, #1642).** A `fn_publish_ai_agent_version` conferia o modelo só em `ai_models`, e nada escreve linha `custom` ali — o catálogo é GLOBAL e o endpoint é de cada empresa. Medido numa VPS real: credencial `custom` validada, rascunho com um modelo que o endpoint devolveu em `/models`, e todo "Publicar" respondendo `model_not_found`. **O que muda:** para `provider = 'custom'`, "o modelo existe" passa a ser conferido em `ai_provider_credentials.models_available` da credencial da versão (a lista que o próprio endpoint devolveu, na linha já conferida quanto a organização, ativa e validada); `custom` sem credencial própria segue `model_not_found`. Os provedores nativos seguem pela consulta ao catálogo, sem mudança. Escrever os modelos em `ai_models` foi recusado: o id servido pelo endpoint de uma empresa apareceria no catálogo da instalação inteira. `create or replace` da versão de 5 argumentos com a mesma assinatura e os mesmos grants (as de 3 e 4 só delegam). Sem coluna, sem dado tocado. Apêndice no `baseline.sql` antes da varredura anon. Gate: `tests/invariants/publicar-com-provedor-personalizado.test.ts`. |
| `20260925223000` | `0417_message_failed_vira_gatilho` | **`message.failed` passa a ser gatilho de verdade (issue #1614).** A 0239 colocou o tipo na lista de `fn_event_log_e_registro` — na época ele era registro, sem consumidor, e a linha nascia `done` para não parecer fila entupida (#753). Com a issue o tipo ganhou consumidor (`automationRulesHandler`, via `ENTIDADE_ESPERADA_POR_GATILHO`) e a lista virou armadilha: `fn_event_log_marca_registro` (BEFORE INSERT) trocaria `pending` por `done` antes de o drain selecionar, e o handler registrado nunca rodaria — sem erro e sem log, o mesmo modo de falha mudo que a 0239 combate. **A função é redefinida sem o tipo** (`create or replace`, mesma assinatura e mesmos grants; `revoke`/`grant` repetidos para o arquivo ficar autocontido). **Sem backfill de propósito:** as linhas antigas nasceram `done` como registro, e reprocessá-las faria a regra rodar hoje por uma falha de semanas atrás, com o estado do contato de agora. **No `baseline.sql` o corpo entra por EDIÇÃO NO LUGAR do bloco ÚNICO** (mesmo desenho da 0412), porque função criada depois da varredura de `anon` é reprovada pela cerca `varredura-anon-e-o-ultimo-bloco`. **Gate:** `tests/unit/evento-de-fato-nao-fica-pendente.test.ts` — a cobrança "tipo com consumidor na lista de registro" passa a ler a ÚLTIMA definição (a que o banco usa), já que a 0239 já rodou em toda instalação e não pode ser apagada. |
| `20260926035000` | `0420_motivo_de_ganho` | **Motivo de ganho nativo (issue #1536).** `crm_leads.won_reason text`, nullable, sem backfill e — deliberadamente — sem CHECK: a obrigatoriedade é opt-in por funil (`settings.won_reason_required`) e a lista é `settings.won_reasons`, as duas decididas no servidor em `lib/leads/campos-exigidos.ts`; uma CHECK tornaria o motivo exigido para todo install e quebraria a escrita de ganho existente num upgrade, enquanto a CHECK da perda (#917) fica intocada. Gravação na MESMA escrita que muda o estado (mesmo desenho do `lost_reason`), validação de vocabulário em aplicação porque não há trigger de ganho. Aparece em `crm_get_lead` e no envelope do webhook (`LEAD_PUBLIC_FIELDS`). Apêndice idempotente no `baseline.sql` antes da varredura anon. |
| `20260926022806` | `0419_rascunho_sugerido_por_integracao` | **Rascunho sugerido por integração, aberto por link e enviado só com clique (issue #1611).** Quem integra tinha duas saídas ruins para a mensagem que precisa sair de uma pessoa: enviar por token (a bolha diz `Sistema`, `sent_by_user_id` nulo e a IA não é silenciada como no envio humano) ou copiar-e-colar. A tabela guarda o TEXTO no servidor — `organization_id`, `conversation_id`, `body` (CHECK de 4096 caracteres, o mesmo teto do envio), `source`, `created_by_api_token_id`, `expires_at` (janela de 24h por padrão), `consumed_at` e `consumed_by_user_id` — e a caixa de entrada abre por `?rascunho=`: o `Composer` recebe o texto via `initialDraft` e mostra a origem, e o envio segue sendo clique humano (`sent_via='user'`, agente silenciado como sempre). RLS `tenant_isolation_conversation_drafts_all` (`for all`, organização nos dois sentidos) porque a leitura é da sessão do atendente e o consumo é um UPDATE da mesma sessão; a escrita da integração usa service role com `organization_id` programático na rota. **LGPD:** `trg_apagar_rascunhos_ao_anonimizar` (em `contacts`, na transição `is_anonymized false → true`, molde de `trg_redigir_tarefas_ao_anonimizar`) apaga os rascunhos das conversas do contato anonimizado — o `body` é texto escrito para a pessoa, e sem FK para `contacts` nem a cascata nem o invariante de cascata a enxergavam. `fn_apagar_rascunhos_do_contato_anonimizado` revogada de public/anon/authenticated. Apêndice no `baseline.sql` antes da varredura anon (é o que faz as travas do suporte cobrirem a tabela já na instalação). Guardado por `tests/unit/rascunho-sugerido.test.ts`, pela linha nova em `tests/invariants/rls-isolation.test.ts` e por `tests/invariants/lgpd-rascunho-do-contato-anonimizado.test.ts`. |
| `20260926040000` | `0422_skill_pointers_legados` | **A tela de Skills não cai quando a instalação veio do formato legado.** Instalações antigas guardavam o nome e a versão ativa em `slug`/`active_version_id`; o contrato atual lê `name`/`version_id`, e um ponteiro nulo fazia o PostgREST recusar a consulta inteira. A migration adiciona as colunas canônicas quando faltam, copia os valores legados sem apagar as colunas antigas, instala a FK para novas escritas e recompõe os índices únicos por organização/plataforma. Registros que não têm versão recuperável ficam fora da resposta da rota em vez de derrubar a tela. Aditiva, idempotente e sem remoção de dados. Gate de rota em `app/api/v1/ai/skills/route.test.ts` e prova de banco em `tests/invariants/skill-pointers-legados.test.ts`. |
| `20260926040100` | `0423_hardening_fn_espelha_nome` | **Hardening de função legada.** Algumas instalações trazem `fn_espelha_nome_e_name()` do CRM anterior, sem `search_path` fixo. A migration só quando a função existir fixa o caminho em `public, pg_temp`, eliminando a resolução de objetos influenciável pela sessão. Não altera dados nem cria a função em instalações novas. |
| `20260926040200` | `0424_skill_versions_legadas_preservadas` | **Desinstalar uma skill legada não apaga seu histórico.** Instalações anteriores guardavam `skill_versions.pointer_id` com `ON DELETE CASCADE`; apagar o ponteiro removia as versões imutáveis, contrariando o contrato de auditoria e rollback. Quando essa coluna existir, a migration reconstrói a FK com `ON DELETE SET NULL`, preservando a versão e soltando apenas o vínculo obsoleto. |
| `20260926120000` | `0425_retomada_como_novo_negocio` | **Retomada de negócio perdido guarda a cadeia de tentativas (issue #1538).** Num funil com `settings.reabertura = "novo_negocio"`, mover um lead encerrado para uma etapa aberta não o reabre: a rota devolve 409 `reabertura_cria_novo` e a saída é `POST /api/v1/leads/{id}/retomar`, que cria um lead novo (`source = 'retomada'`) apontando para o encerrado pela coluna nova `crm_leads.retomado_de_lead_id uuid` — daí "quantas tentativas até fechar" é derivado por SQL, sem ler `source_metadata`. FK para a própria tabela com `ON DELETE SET NULL` (apagar um negócio solta o ponteiro da tentativa nova, em vez de recusar a exclusão LGPD), constraint guardada por `pg_constraint` e índice parcial `where retomado_de_lead_id is not null`. Aditiva e idempotente; coluna nasce `null` em toda linha existente (nenhuma retomada existia antes disto). Sem função nova, sem policy nova: o escopo de leitura/escrita é o de `crm_leads` de sempre. |
| `20260926150000` | `0426_motivo_de_perda_com_categoria` | **Motivo de perda com categoria e a etapa de onde o negócio saiu (issue #1537).** Três mudanças aditivas: (1) `crm_leads.lost_from_stage_id uuid` com FK `on delete set null` e constraint guardada por `pg_constraint` — em que etapa ABERTA o negócio morreu, sem a qual o relatório não diz em que momento o funil vazou; nasce `null` em toda linha existente, sem backfill (derivar retroativamente seria inventar dado). (2) `fn_crm_lead_close_on_stage` grava a origem SÓ na transição para `lost` (um card já perdido mantém a origem verdadeira; reabrir zera) — o mesmo gatilho que já deriva `status`/`closed_at`, sem segundo gatilho na mesma transição; todo caminho de perda move a etapa e ele é o único escritor de `status` (P-02). (3) `fn_validate_lost_reason_required` compara o RÓTULO de `settings.lost_reasons` quando o item é `{ label, categoria }` (`jsonb_typeof(e) = 'object'` → `e->>'label'`, string → `#>> '{}'`): sem isso o trigger recusaria com 22023 um motivo que a própria tela ofereceu. Texto puro segue valendo igual — nenhum funil migra dado. Funções redefinidas com `create or replace` (assinatura e grants inalterados, `revoke`/`grant` repetidos para o arquivo ficar autocontido) e corpos também EDITADOS NO LUGAR no `baseline.sql` (bloco único do close, ÚLTIMA definição do validate) mais apêndice idempotente da coluna. Gate: `tests/unit/lost-from-stage-id-no-gatilho.test.ts`. |
| `20260926160000` | `0427_probabilidade_de_ganho_por_etapa` | **Probabilidade de ganho por etapa, base da previsão ponderada do funil (issue #1535, de @webtecnica).** Aditiva: `crm_stages.win_probability smallint` com CHECK `null or 0..100` (`crm_stages_win_probability_range`, recriada com `drop ... if exists` para o arquivo ser reaplicável). Nasce `null` em toda linha existente e `null` quer dizer "etapa sem probabilidade calibrada" — a regra de previsão (`lib/leads/previsao.ts`) reporta esses negócios num balde À PARTE, nunca como zero em silêncio. Etapas `is_won`/`is_lost` valem 100 e 0 NA REGRA, não gravado: gravar seria um segundo lugar para a mesma verdade divergir. Nenhum backfill: nada muda até o gestor calibrar. Apêndice idempotente no `baseline.sql`, depois do da 0426. |
| `20260926170000` | `0428_candidatos_ao_golden_na_tabela` | **O candidato ao golden set sai do disco e vira linha de rótulo (issue #1695).** O matcher de skills (F3-09) e o classificador de etapa (F3-11) gravavam JSON em `GOLDEN_CANDIDATES_DIR`: dentro do repositório em desenvolvimento (`git add -A` publicava conversa de cliente — medida na issue) e no disco do contêiner em produção, onde nenhuma tela lê, some na atualização da imagem e fica FORA da cascata de anonimização. O #1708 redigiu o texto pelo `scrubMessage` e gitignorou os dois prefixos; esta migration tira o candidato do disco. `public.golden_candidates` guarda SÓ rótulo — `fonte` (`skill_match_miss`/`stage_classifier_divergence`) + `skill`/`motivo` ou `estagio_sugerido`/`estagio_confirmado`, com CHECK de formas disjuntas — e ponteiros `lead_id`/`job_id` SEM FK (mesmo motivo da 0421: não travar a anonimização nem cair com o histórico); a conversa que a curadoria lê fica atrás do ponteiro, não numa cópia fora da cascata. Idempotência do retry por dois índices ÚNICOS parciais (um candidato por skill+job, uma divergência por job) + `on conflict do nothing` do gravador — o "um arquivo por candidato" do tempo em disco. RLS: só SELECT por membro (`tenant_isolation_golden_candidates_select`); escrita só do servidor. `fn_expurgar_candidatos_do_golden(p_retencao_dias, p_limite)` — `security definer`, padrão 90 / piso 30 no CORPO, revogada de `public`, `anon` e `authenticated`, só `service_role` — drenada em lotes pelo cron `data-retention` (`GOLDEN_CANDIDATES_RETENTION_DAYS`). Aditiva e idempotente. Apêndice no `baseline.sql` antes da varredura de anon. |
| `20260926200200` | `0432_poda_de_midia` | **A retenção de mídia passa a existir.** `fn_enfileirar_midia_vencida(p_limite)` (security definer, só `service_role`) enfileira em `storage_redaction_queue` o arquivo de mensagem mais velho que `organizations.media_retention_days` (piso 30; zera `messages.media_storage_path`, a mensagem fica) e o arquivo órfão de `whatsapp-media` (pastas `org/<conversa>/` e `org/avatars/` sem referência, com um dia de carência; `org/templates/` nunca). O cron `storage-redaction` remove pelo Storage API. Medido em 23/09/2026: 1,30 GB órfãos levaram um Supabase gratuito à restrição 402. |
| `20260926223000` | `0434_reenfileiramento_de_midia` | **Reenfileirar um caminho de mídia que já saiu volta a funcionar; linha `deleted` antiga é expurgada (issue #1739, achado na triagem do #1731).** As DUAS inserções de `fn_enfileirar_midia_vencida` usavam `on conflict (bucket, object_path) do nothing` sobre um `unique (bucket, object_path)`, e o worker marca a linha como `deleted` sem nada a remover depois — dois defeitos: um arquivo NOVO gravado num caminho que já saiu era engolido em silêncio (nunca mais podado nem pela retenção nem pela LGPD) e a fila crescia sem teto. **Medido antes de consertar (#1739, passo 1): o reaproveitamento de `object_path` EXISTE** — `app/api/v1/cron/contact-avatars/route.ts:204` grava `${org}/avatars/${contactId}.jpg` com `upsert: true` (caminho estável por contato, regravado a cada refresh de 7 dias, enfileirado de novo pela cascata `lib/lgpd/redact-cascade.ts` e pelo próprio cron) e `workers/media-persist-worker.ts:122` usa `storagePathFor` = `{org}/{conversa}/{mensagem}.{ext}` (determinístico por mensagem, regravado no retry); os outros três emissores (`conversations/[id]/media`, `products/[id]/fotos`, `channels/partner/templates/media`) usam `randomUUID()` e nunca reaproveitam. Logo o item 1 da issue não é teórico e o conserto é o `on conflict ... do update`, não só o expurgo. **Regra:** `do update set status='pending', attempts=0, enqueued_at=now(), processed_at=null, error_message=null `where storage_redaction_queue.status in ('deleted','skipped')` — reabre SÓ o que já terminou; `pending` e `failed` em curso não são tocados (é o `where` que garante). O filtro de órfãos passa a contar só linha em curso como «já na fila» (`status not in ('deleted','skipped')`): sem isso o `not exists` segurava o caminho antes de o conflito acontecer e o `do update` jamais rodaria. **Expurgo:** `delete ... where status='deleted' and request_id is null and coalesce(processed_at, enqueued_at) < now() - 90 days` no CORPO da mesma função, ou seja, no cron diário `media-retention` — sem cron novo, sem coluna, sem índice, sem FK para `storage_redaction_queue` (medido no baseline). `failed` não é expurgada (a issue manda não mexer; é o registro de uma remoção que não passou das 3 tentativas). A linha `deleted` de pedido LGPD (`request_id` não nulo) também fica: é o único registro por objeto de que a mídia do titular saiu do bucket — o worker só troca o `status` e o cron `storage-redaction` não audita a remoção física (ajuste da triagem). Mesma assinatura (1 argumento) e par `revoke`/`grant` repetido; corpo EDITADO NO LUGAR no apêndice do `baseline.sql` (mesmo desenho das 0417/0426). Gate: `tests/invariants/poda-de-midia-reenfileiramento-medido.test.ts` (Postgres real: `deleted`/`skipped` reabrem, `pending`/`failed` ficam, expurgo dos 90 dias e a segunda rodada continua idempotente) e `tests/invariants/poda-de-midia-expurgo-preserva-lgpd.test.ts` (a linha `deleted` de pedido LGPD sobrevive ao expurgo). |
| `20260927015500` | `0435_contagem_do_expurgo_da_fila` | **A contagem do expurgo da fila aparece no retorno e na trilha (issue #1765, continuação da #1739).** A 0434 passou a expurgar, no CORPO de `fn_enfileirar_midia_vencida`, a linha `deleted` da RETENÇÃO com mais de 90 dias (`request_id is null`) — o DELETE está lá, roda todo dia, e a sua contagem não aparecia em lugar nenhum: a função devolvia só `{vencidas, orfas}`, e `app/api/v1/cron/media-retention` auditava `retention.sweep_run` só quando `vencidas + orfas > 0`. As duas lacunas são a MESMA linha de código vista de dois lados: **a rodada que só expurgou (as duas contagens zeradas) não auditava**, sendo que é a rodada em que a fila perde linhas de verdade; e a linha de rastro que existia não dizia quantas saíram da fila. **O conserto:** `get diagnostics v_expurgadas = row_count` logo após o DELETE do passo 0 e a chave `expurgadas` no `jsonb_build_object` de retorno — contagem de linha APAGADA nesta chamada, que é o que permite ao cron decidir se houve efeito (uma contagem do que «será» apagado não permitiria, porque o DELETE já aconteceu quando a função retorna). **No cron:** `expurgadas` entra no acumulado e na soma da condição de auditoria (`vencidas + orfas + expurgadas > 0`), e sai na metadata do `retention.sweep_run` — que é o que o runbook de custo do Supabase lê para saber quanto a fila perdeu. O expurgo NÃO entra na condição de PARADA do laço de tandas: ele é livre de `p_limite` e a segunda chamada da mesma rodada devolve 0 para ele de qualquer jeito, então parar por causa dele seria parar uma tanda antes de enfileirar o que ainda cabe. **O que NÃO muda:** janela de 90 dias, filtro (`status='deleted'`, `request_id is null`, `coalesce(processed_at, enqueued_at)`), ordem dos passos e as demais podas — `failed`, `skipped` e a linha de pedido LGPD continuam de fora, cada uma pelo motivo escrito no cabeçalho da 0434. Mesma assinatura (1 argumento) e par `revoke`/`grant` repetido. A 0434 está aplicada (1.54.0) e não é editada: o corpo no `baseline.sql` é EDITADO NO LUGAR do bloco dela (mesmo desenho das 0417/0426/0434), derivado deste arquivo, não reescrito. Gate: `tests/invariants/poda-de-midia-contagem-do-expurgo.test.ts` (Postgres real: a contagem bate com as linhas que saíram, a rodada que só expurgou devolve `vencidas`/`orfas` zerados com `expurgadas > 0`, a segunda rodada expurga 0, e o `toEqual` congelado do `poda-de-midia.test.ts:102` recebe o campo novo no objeto esperado, porque o `toEqual` do Vitest é exato EM CHAVE — a chave nova é aditiva em VALOR, não em chave, e sem essa edição o `test:invariants` saía != 0 exatamente nos três `toEqual` congelados que comparam o retorno inteiro (`:102`, e o irmão `poda-de-midia-reenfileiramento-medido.test.ts:150` e `:176`); a edição não afrouxa nada — os três continuam exatos e passam a fiscalizar a contagem do expurgo também, e a justificativa está no PR com a válvula `DESKCOMM_GOV_INVARIANTS_EDIT=1` exportada) e `tests/unit/cron-media-retention-audita-o-expurgo.test.ts` (a rota soma, audita a rodada que só expurgou e publica `expurgadas` na metadata). |
| `20260927140100` | `0440_etapa_avisa_na_central` | **A etapa pode avisar a equipe na Central quando um negócio entra nela.** `crm_stages.avisar_na_central boolean not null default false`. Numa venda com pagamento na entrega, o momento que pede ação da equipe é o cliente confirmar o pedido com os dados — uma etapa, não o `is_won` (o ganho só vem com a entrega). Qual etapa é essa é decisão da organização, daí a marca por etapa, desligada por padrão. Quem lê é `lib/leads/aviso-de-etapa.handler.ts`, no evento `lead.stage_changed`: abre um aviso `other` com `ref_kind = lead` (o destino «Abrir negócio» que a Central já tem), sem dado pessoal no texto — só o nome da etapa — e sem empilhar enquanto o anterior do mesmo negócio e etapa estiver aberto. **Por que `other` e não um kind novo:** um kind novo exigiria reconstruir o CHECK de `agent_inbox_items` no bloco único do baseline e na cadeia, para um aviso que é genérico por natureza. Aditiva e idempotente. |
| `20260927140200` | `0441_sons_dos_avisos` | **O bucket privado `org-sounds` para os sons dos avisos da Central** — o negócio que entrou numa etapa que avisa (0440) e o pedido de pessoa (a IA passou a conversa, ou ficou sem saldo no provedor). 1 MB, MP3/OGG/WAV, `public = false` e **nenhuma** policy em `storage.objects`: só o `service_role` lê e grava, pela rota `app/api/v1/settings/sons` (GET qualquer membro, com URL assinada de 1 h; POST/DELETE `manager`+, tipo farejado pelos bytes, caminho gerado no servidor sob `<organization_id>/`). O caminho fica em `organizations.settings.sons_de_aviso` — jsonb, sem coluna nova (C da DIRC: é configuração da organização, não entidade). Idempotente (`on conflict do update`). Gate: `tests/invariants/sons-dos-avisos.test.ts`. |
| `20260927140300` | `0442_aviso_da_central_no_barramento` | **O aviso da Central anuncia no barramento que nasceu.** Trigger `trg_aviso_da_central_criado` (AFTER INSERT em `agent_inbox_items`) emite `central.aviso_criado` com item, kind e ref; o consumidor é `lib/notifications/push.handler.ts`, que manda ao celular só os avisos que pedem gente — a MESMA regra do som da Central (`somDoAviso`): passagem para pessoa, IA sem saldo no provedor e negócio que entrou numa etapa que avisa (0440). **Trigger e não emissor em código** pelo motivo da 0148: aviso nasce em vários lugares (motor, rotas, handlers, crons), e o próximo caminho nasceria mudo. **Sem I/O no trigger** (doutrina: trigger nunca faz HTTP); o `emit_event` fica num bloco que engole a falha com `raise warning`, porque um aviso de passagem que não grava por causa do anúncio seria o pior desfecho. Aviso de plataforma (organização nula) não emite. `security definer` com `search_path` fixo e as duas origens de EXECUTE revogadas (sem grant: é função de trigger). Idempotente (`create or replace` + `drop trigger if exists`). Gate: `tests/invariants/aviso-da-central-no-barramento.test.ts`. |
| `20260927100000` | `0436_conversao_google_por_etapa` | **Conversão do Google Ads por etapa do funil, e a venda sem valor.** Tabela nova `google_ads_conversion_rules` (uma regra por etapa: ação de conversão, rótulo, categoria, canal de entrada `todos`/`whatsapp`/`outros`, ligada/desligada), FK composta para `crm_stages (organization_id, id)` com `on delete cascade`, únicos por (org, etapa) e (org, `event_name`). `event_name` é a chave do livro-razão: `Etapa:<uuid>` para regra nova e `QualifiedLead` para a qualificação da 0402, migrada como primeira regra COM o nome de sempre (negócio já enviado não é reenviado com outro nome) e com o `configured_at` herdado. `fn_marcar_configuracao_regra_google` (gatilho, só `service_role`) regrava `configured_at` quando etapa/ação mudam ou a regra é religada — trava de retroatividade. Em `ad_platform_connections`: `google_purchase_value_mode` (`obrigatorio` padrão = comportamento de sempre, `quando_houver`, `nunca`), `google_purchase_category`, `google_send_hashed_phone` (padrão desligado). `fn_solicitar_reenvio_conversao(uuid, uuid, text)` passa a aceitar `Etapa:<uuid>` com a mesma exigência de retrato da qualificação. RLS sem policy e grants revogados de anon/authenticated, como a 0213. Aditiva e idempotente. |

| `20260927110000` | `0437_links_rastreaveis` | Links nomeados por organização, refs nas tabelas existentes e métricas dos registros retidos. FKs compostas impedem clique em link de outro tenant. RLS sem policies, grants do navegador revogados e RPC exclusiva de service_role. Desativação preserva o histórico. |
| `20260927120000` | `0438_aviso_de_caso_ignora_conexao_arquivada` | **O número de uma conexão arquivada volta a poder receber os avisos.** A checagem de "número da própria organização" da 0292 perguntava por `channel_sessions` sem filtrar `archived_at`, então a conexão arquivada continuava contando: quem a removeu não conseguia escolhê-la como destino do aviso (`aviso_de_caso_numero_da_propria_org`) — e não conseguiria nunca mais, porque a conexão que já teve agente publicado não pode ser apagada (as versões a seguram). `create or replace` da função inteira, mesma assinatura e o mesmo par `revoke`/`grant`; a conexão arquivada passa a ficar de fora, como já fica no índice único parcial da 0107 e em `listSelectableChannels`. Sem coluna, sem dado tocado, sem policy nova. Bloco da 0292 EDITADO NO LUGAR no `baseline.sql` (mesmo desenho das 0417/0426/0434/0435): um apêndice novo com o corpo novo divergiria do bloco antigo para quem aplica a cadeia. Gate: `tests/unit/aviso-de-caso-nao-conta-conexao-arquivada.test.ts` (as duas definições, cadeia × apêndice) e o caso de Postgres real acrescentado a `tests/invariants/aviso-de-caso-escrita.test.ts` (o número de conexão arquivada passa, o de conexão ativa continua recusado). |
| `20260927130000` | `0439_aviso_de_caso_confere_o_destino_no_envio` | **O aviso de caso confere no ENVIO se o destino voltou a ser número da própria organização.** A guarda anti-laço da 0292 pergunta pelo número da própria organização só ao DEFINIR o aviso; a 0438 passou a ignorar a conexão ARQUIVADA (não envia nem recebe), o que era o conserto certo do número bloqueado para sempre. Mas arquivar não apaga a linha — reativar é gravar `archived_at = null` de novo —, então reativar uma conexão cujo número já estava gravado como destino fazia o aviso sair para um número atendido por um agente DESTA organização, sem nenhuma checagem de novo. O motor (`lib/escalacao/aviso-ao-suporte.ts`, passo 11b) passa a perguntar isso antes de resolver o destino e, quando é o caso, CONDEMA a entrega com o código novo `destino_da_propria_organizacao` em vez de enviar — grava a linha em `entregas_de_aviso_de_caso`, abre item na Central e audita `ai.case_alert_failed`, como as outras recusas definitivas. A pergunta fica no ENVIO e não na reativação de propósito: a linha é ressuscitada por três caminhos (canal oficial, pareamento do onboarding, `finish` do WAHA) e uma guarda por caminho nasceria desatualizada no quarto. Este arquivo só amplia o vocabulário fechado de `entregas_de_aviso_de_caso.erro_codigo` com o código; sem tabela, coluna, policy ou dado tocado. Bloco da 0292 EDITADO NO LUGAR no `baseline.sql`, como a 0438 fez. Gates: `tests/unit/aviso-de-caso-confere-o-destino-no-envio.test.ts` e os casos acrescentados a `tests/unit/aviso-de-caso-decide.test.ts`. |
| `20260927160000` | `0443_icone_da_aba` | **O ícone da aba pode ser um arquivo.** Coluna opcional `platform_branding.favicon_path` (mesmo CHECK de caminho do logo, prefixo `platform/`, PNG ou JPG), gravada só pela rota `/api/v1/marca/logo` com `peca=icone`. Sem arquivo, a aba segue com o ícone desenhado por `app/icon.tsx`. Aditiva: código anterior ignora a coluna. |
| `20260928120000` | `0444_relatorio_financeiro_por_moeda` | **O relatório financeiro separa as moedas (#1531).** `fn_relatorio_financeiro` somava `financial_entries.amount_cents` e `sales.total_cents` sem olhar a moeda, e a tela de Faturamento escrevia o resultado em real. Ganha a chave `por_moeda`: para cada moeda com movimento no período (lançamentos pagos e comandas finalizadas), os mesmos totais e listas do topo, só com as linhas daquela moeda — ticket médio com a mesma conta do topo e top 10 por moeda; `{}` sem movimento. A comissão e o item herdam a moeda da comanda pelo join. Aditiva: assinatura, `stable`, INVOKER, `search_path`, grants e as treze chaves antigas ficam iguais (as do topo seguem somando todas as moedas, como antes). Bloco da 0353+0356 EDITADO NO LUGAR no `baseline.sql`, como o próprio bloco manda. Gate: `tests/invariants/relatorio-financeiro-por-moeda.test.ts`. |
| `20261002170000` | `0511_catalogo_oficial_instala` | **Nenhuma entrada do catálogo oficial instalava.** A conferência do pacote em `fn_extensions_finish_install` comparava os dois COMPLEMENTOS — `(manifesto − 6 chaves)` contra `(entrada − {sha256, byte_length})` —, e a 0282 passou a admitir cinco campos de vitrine na entrada (`publisher_label`, `homepage`, `repository`, `tags`, `published_at`) que o pacote nunca pode ter. Como eles sobram de um lado só, a comparação divergia sempre: instalar qualquer entrada de `extensoes/catalogo.json` morria em `extension_artifact_mismatch`, com o recibo preso em `preparing`. O conserto é de FORMA e não de lista: a conferência passa a projetar os dois lados sobre as SETE chaves que a entrada declara sobre o pacote (`publisher`, `name`, `version`, `license`, `host_api`, `display`, `permissions`), então campo de vitrine novo deixa de quebrar a instalação. As cercas que davam a garantia ficam de pé — chave nova no manifesto segue barrada pela subtração das treze no mesmo `if`, e chave nova na entrada segue barrada por `fn_extensions_admit_catalog`. `create or replace` da função inteira, mesma assinatura e o mesmo par `revoke`/`grant`; sem tabela, coluna, policy ou dado tocado. As DUAS definições do `baseline.sql` foram EDITADAS NO LUGAR (eram idênticas, mesmo desenho das 0438/0439/0444). Gate: o caso novo em `tests/invariants/extensoes-declarativas.test.ts`, com o controle que mantém o mismatch quando um metadado anunciado divergir do pacote. |
| `20260928132000` | `0448_empresas_pessoas_importacao` | **Empresas, pessoas que decidem e importação de planilha — a metade B2B do #1621 (contribuição de @renatofortal), atrás do módulo opcional `MODULO_CRM_B2B`, desligado por padrão (doc 68, opção b).** Cinco tabelas: `companies` (razão social, CNPJ normalizado com único parcial por organização, dados da BrasilAPI), `people` (a pessoa que decide), `company_people` (o vínculo, com cargo), `import_batches`/`import_rows` (o lote de planilha e cada linha como veio). `contacts.person_id` (nullable, `on delete set null`): o contato é um telefone da pessoa; o WAHA segue criando contato sem pessoa. Gatilhos `security invoker` de mesma organização em `company_people`, `contacts.person_id` e `import_rows`. RLS POR OPERAÇÃO espelhando as rotas (SELECT membro; INSERT manager; UPDATE agent nas três primeiras e manager no importador; DELETE manager) — o PR original tinha `for all` org-flat, e o `GRANT ALL ... TO authenticated` do baseline deixaria um `viewer` escrever pelo PostgREST. Gate: `tests/invariants/companies-people-rls.test.ts` e as cinco em `rls-isolation.test.ts`. |
| `20260928132100` | `0449_lgpd_alcanca_empresas_e_pessoas` | **A anonimização alcança a pessoa e a linha de planilha importada.** Gatilho `trg_redigir_b2b_ao_anonimizar` na virada de `contacts.is_anonymized` (molde de `trg_redigir_tarefas_ao_anonimizar`): `import_rows` do contato OU da pessoa dele perde `raw_data`, `normalized_data` e `error`; `people` troca o nome pelo rótulo e perde e-mail e anotações; `company_people` perde cargo, departamento e anotações. A empresa não é tocada (é da pessoa jurídica) e os OUTROS contatos da pessoa também não (telefone pode ser linha dividida; anonimizar é irreversível). Gatilho, e não passo em `fn_lgpd_cascade_redact_contact`: a 0477, de timestamp posterior, reescreve o corpo inteiro da função e apagaria o passo na cadeia. O export do titular (`lib/lgpd/export-collector.ts`) lê as três tabelas. Gate: `tests/invariants/companies-people-rls.test.ts`, `lgpd-redact-unificado-alcanca-pelo-catalogo.test.ts` e `tests/unit/lgpd-exporta-o-que-redige.test.ts`. |
| `20260928140000` | `0462_configuracoes_de_propostas` | **Configurações de propostas por organização em `organizations.settings.proposals`.** `enabled` (boolean, default false — nasce desligada), `default_valid_days` (int, default 15), `default_conditions` (text nullable). A rota POST de `app/api/v1/proposals` usa a validade padrão quando `valid_until` não vem no payload. Tela de configuração em `app/app/settings/tenant/proposals`. |
| `20260928140100` | `0463_proposta_ai_draft_enabled` | **Chave na versão do agente para auto-injeção de ferramenta MCP.** `ai_agent_versions.proposal_ai_draft_enabled` (boolean, default true) — o agente pode rascunhar proposta sozinho quando a capacidade "Propostas" está ligada na organização. Acompanha o padrão de `handoff_tool_enabled`: coluna de feature flag, auto-injeção via `pickToolsFromMcp`, propagação de valor pela cadeia de runtime do agente. Toggle na tela de edição do agente (padrão: ligado). Ferramenta MCP correspondente `crm_draft_proposal` (category `write`, role `agent`) rascunha proposta com `drafted_by_agent_id` preenchido. |
| `20260928140200` | `0464_proposta_comercial` | **A proposta comercial ganha tabela própria — `crm_proposals`/`crm_proposal_items`, RLS por organização, numeração sequencial por organização/ano alocada só no envio, e versão (a revisão de uma enviada cria uma v2 que herda o número).** Trava de organização do item (`fn_verificar_org_do_item_da_proposta`), `fn_proposta_aloca_numero` só para `service_role`, bucket privado `propostas` com leitura por organização (`split_part`). Três `kind` novos em `agent_inbox_items` (vencimento, laço de retorno, promessa não cumprida) — reconstruída com a lista completa vigente então (nesta branch 0466 e 0475 ainda reconstroem depois; no `baseline.sql` a edição é no bloco único dela). `crm_tasks.source_kind` (vocabulário aberto) para a promessa virar tarefa. |
| `20260928140300` | `0465_a_proposta_nao_aponta_para_outra_organizacao` | **Trigger `trg_crm_proposals_org_consistente`: a proposta não grava `lead_id`, `contact_id` nem `conversation_id` de outra organização** — a RLS só conferia o `organization_id` da própria linha. Molde de `fn_verificar_org_do_item_da_proposta`. Referência nula passa (o conserto da proposta enviada que sobrevive ao negócio vai tornar `lead_id`/`contact_id` anuláveis); só escrita nova é validada, então o `update.sh` de clone com dado legado não quebra. |
| `20260928140400` | `0466_c2_numeracao_envio_e_sobrevivencia` | **Onda C2 da spec de Propostas (D9+D3+D10).** D9: número reemitido a dois clientes em produção (auditoria org 59914589, 19/09/2026) porque `fn_proposta_aloca_numero` calculava `max(numero)+1` sobre linhas que existem — apagar a linha liberava o número. `crm_proposal_counters` (organization_id, ano, ultimo_numero) nunca deriva de linha nenhuma; a função vira `volatile`, um UPSERT atômico, sem laço de repetição. Semente do backfill: maior número visto em `crm_proposals` OU em `api_audit_log` (`proposal.sent`), o que for maior. D3: status ganha `enviando` — número reservado ao entrar nesse estado, `enviada` só quando a mensagem de WhatsApp confirma `sent`/`delivered` (nunca pela ausência de exceção); colunas `message_id` e `ultima_falha_envio`; kind novo `proposta_travada` no bloco único de `agent_inbox_items_kind_check` (cron de recuperação como o `recover-stuck-messages`). D10: `crm_proposals.lead_id`/`contact_id` viram nullable com `on delete set null` (eram `not null`/`cascade` — apagar o negócio apagava em cascata um documento já entregue ao cliente); coluna `destinatario_nome` grava o nome impresso no PDF; trigger `trg_crm_leads_cancelar_propostas_rascunho` cancela rascunho (sem valor fora do negócio) antes do delete, e propostas enviada+ sobrevivem órfãs. |
| `20260928140500` | `0467_c3_precificacao_e_rascunho_unico` | **Onda C3 da spec de Propostas (D5 + §5.1/5.2/5.3).** pricing_status em crm_proposals (missing/catalog/manual/approved, default missing, backfill recalculado dos proprios itens); crm_proposal_items.preco_unitario_cents passa a aceitar NULL (CHECK >= 0 continua valendo para valor presente); indice unico parcial um-rascunho-aberto-por-negocio com dedupe previo (demais viram cancelada, nunca delete). |
| `20260928140600` | `0468_m0_proposta_referencia_modelo` | **Onda M0 da spec de Propostas (fundamento de modelos).** `crm_proposals` ganha `template_slug`, `template_version`, `template_snapshot`, `rendered_snapshot` — nullable e aditiva, sem migração de dado (todo o histórico convive sem modelo). Snapshots ficam vazios até a Onda M5 (envio). CHECK `crm_proposals_template_slug_versao_juntos_check`: slug e versão nascem e morrem juntos. Gate: caso no `tests/invariants/proposta-templates-isolamento-entre-organizacoes.test.ts` (slug sem versão e versão sem slug → 23514). |
| `20260928140700` | `0469_c4_revisao_por_versao_e_auditoria` | **Onda C4 da spec de Propostas (D4).** Índice de numeração passa a incluir `versao` — v1 `enviada` e v2 `rascunho` da mesma cadeia convivem com o mesmo `numero`/`ano` até a v2 ser enviada (só então a v1 vira `substituida` e sai da unicidade). Com trava pré-troca (doutrina de migrations item 8): se algum grupo já violar a chave nova, a migration PARA em vez de furar o próprio índice. |
| `20260928140800` | `0470_e1_followup_e_orcamento` | **Onda E1 da spec de Propostas (N2).** `crm_proposals.retorno_id uuid references cron_jobs(id) on delete set null` — guarda QUAL retorno automático foi agendado ao enviar, para cancelá-lo se o cliente decidir (aceita/recusada) antes da data marcada. NULL = nenhum agendado (falha ao agendar não bloqueia o envio) ou já cancelado/disparado. Aditiva e idempotente (`add column if not exists`). |
| `20260928140900` | `0471_m0_tabela_de_modelos` | **Onda M0 da spec de Propostas (fundamento de modelos).** `proposal_templates` guarda só CÓPIAS por organização (`organization_id not null`, spec-mãe §6.1 — a base da plataforma mora no código, `MODELOS_BASE`, nunca no banco com organization_id nulo). `base_slug`/`base_version` guardam de qual modelo da base a cópia veio (spec-mãe §6.1). SEM CHECK fechado de `slug` (os 8 modelos-piloto não estão no repositório; validação fica no Zod até o piloto ser definido). Índice único parcial `(organization_id, slug) where is_active` (só uma versão ativa por slug/org) + único `(organization_id, slug, version)`. RLS em duas policies (molde de `crm_proposals`, 0464): SELECT aberto ao membro da org, WRITE com piso `fn_role_at_least(organization_id, 'agent')` — a revisão final achou a policy original sem piso de papel (qualquer `viewer` escrevia modelo) e corrigiu antes do merge. `revoke ... from anon` explícito. Gate: `tests/invariants/proposta-templates-isolamento-entre-organizacoes.test.ts` (mesmo slug nas duas orgs, RLS por membro, piso de papel na escrita, colisão de versão ativa). |
| `20260928141000` | `0472_m1_briefing_e_revisao` | **M1 (onda de modelos) — o rascunho ganha briefing e motivo de revisão.** Cinco colunas nullable em `crm_proposals`: `briefing_json` (insumo estruturado do briefing, auditável), `prazo_dias_uteis`, `pagamento`, `resumo_comercial` (gerado na emissão, nunca digitado), `version_reason` (motivo da revisão). Sem CHECK — nenhuma tem vocabulário fechado na spec de 21/09 (§5.2). Sem backfill: proposta existente fica com as cinco `null`. |
| `20260928141100` | `0473_m3_secoes_editadas` | **M3 (onda de modelos) — edição manual por seção do documento.** Coluna `secoes_editadas jsonb` nullable em `crm_proposals`: mapa `{secaoId: textoEditado}` que a rota `GET/PATCH /api/v1/proposals/[id]/documento` lê e escreve. Sem CHECK — chave é o id de seção do modelo em uso, vocabulário aberto. Sem backfill. |
| `20260928141200` | `0474_ia_sugere_modelo_da_proposta` | **N1 — a IA sugere o modelo, uma pessoa confirma (decisão do dono, 25/09/2026).** Coluna `template_slug_sugerido text` nullable em `crm_proposals`: estado provisório gravado por `crm_draft_proposal`, limpo ao confirmar (vira `template_slug`) — nunca entra na constraint `crm_proposals_template_slug_versao_juntos_check`, que é só sobre o par confirmado. Sem CHECK, sem FK, sem backfill. |
| `20260928141300` | `0475_proposta_pronta_para_revisao` | **N2 — a Central avisa quando uma proposta rascunhada pela IA precisa de revisão (decisão do dono, 25/09/2026).** Kind novo `proposta_pronta_para_revisao` no bloco único de `agent_inbox_items_kind_check` (última reconstrução da cadeia, repete todo o vocabulário da 0464 + o valor novo antes de `other`). Nasce ao rascunhar (`crm_draft_proposal`) e se resolve quando modelo confirmado E preço resolvido, ou ao enviar/descartar. |
| `20260928141400` | `0476_modelos_de_proposta_da_empresa` | **P5 (spec de 26/09) — a empresa cadastra os próprios modelos de proposta.** Colunas `nome`/`descricao` nulláveis em `proposal_templates` (a tabela da M0 não tinha nada legível por pessoa) e a política `proposal_templates_write` passa de `agent` para `manager`+ (decisão #10 da spec de 21/09; nenhum código escrevia como agent). Aditiva, sem backfill. |
| `20260928141500` | `0477_redact_alcanca_crm_proposals` | **A cascata de anonimização (LGPD) não alcançava `crm_proposals` (issue #1504, achado por `tests/invariants/lgpd-redact-unificado-alcanca-pelo-catalogo.test.ts`).** Decisão: redigir, caminho cascata, molde de `orders`/`crm_leads` — preserva número, valores, itens, datas e status; redige `destinatario_nome` (nome impresso no PDF, D10/0466 — recebe o rótulo, não `null`), `briefing_json` e `resumo_comercial` (texto sobre o negócio do cliente). `template_slug_sugerido` fica de fora — é slug de modelo, nunca dado do contato. Corpo de `fn_lgpd_cascade_redact_contact` copiado por inteiro da última definição (não de uma versão antiga) para não apagar em silêncio os passos que entregas seguintes acrescentaram (sales, campanhas, prospecção etc.); só o passo `crm_proposals` é novo. Nesta branch o `baseline.sql` recebe o corpo vigente no bloco novo da 0477, no fim do arquivo. **O PDF também:** todo `pdf_path` do contato entra em `storage_redaction_queue` com o bucket `propostas` (o passo 7 só enfileira `whatsapp-media`), e `pdf_path`, `rendered_snapshot` e `secoes_editadas` saem da linha (conserto da triagem; gate `tests/invariants/proposta-anonimizar-expurga-o-pdf.test.ts`). |
| `20260928150000` | `0478_notas_internas_realtime_e_visibilidade` | **As notas internas ganham realtime e passam a seguir a visibilidade da conversa (#1863, F1+F2).** F1: `conversation_notes` estava FORA da publicação `supabase_realtime` (lista explícita no `foreach` do baseline) — o hook assinava, o Supabase respondia `SUBSCRIBED` e nenhum evento chegava, falha muda documentada no próprio docblock de `app/api/v1/conversations/[id]/passagens/route.ts`; a anotação só aparecia quando alguém recarregava. F2: a policy testava só `fn_user_org_ids()`, enquanto `fn_can_view_conversation` (21 usos) é quem implementa `visibility_mode` (`all` | `own_and_unassigned` padrão | `own`) — em `own_and_unassigned` o atendente que não abria a conversa lia as notas dela e quem perdeu a conversa continuava vendo. Molde: `tenant_isolation_ai_reply_drafts_all`. `exists` em `conversations` com `c.organization_id = conversation_notes.organization_id` (trava conversa de outra org em dado legado). O ramo `or fn_is_platform_admin()` da política antiga vira policy própria — o admin de plataforma não é membro de organização nenhuma. Sem coluna, sem backfill, sem dado tocado; escrita (`conversation_notes_write`) passa a exigir a mesma visibilidade — `for all` concede SELECT e anularia a leitura nova. Idempotente nas duas pontas. Gates: `tests/invariants/notas-internas-respeitam-a-visibilidade-da-conversa.test.ts` (leitura) e `tests/invariants/notas-internas-escrita-e-admin-de-plataforma.test.ts` (escrita e admin de plataforma). |
| `20260928150400` | `0482_grupos_na_inbox` | (Vem depois da `0404` de propósito — `comando_da_conversa` herda a forma SECURITY DEFINER/sem nome da 0404 e só acrescenta `is_group`.) **Grupos de WhatsApp na inbox, só os escolhidos.** `channel_session_groups` (lista de permitidos por número, RLS: membro lê, só o service role escreve), `contacts.kind` (`person`/`whatsapp_group`), roteamento pula grupo, e `fn_emit_message_event` emite `message.group_received` para conversa de grupo — nenhum consumidor de `message.received` vê grupo. Índice único `uq_contacts_grupo` (organização + `group_chat_id`) impede placeholder duplicado do mesmo grupo. Spec `docs/superpowers/specs/2026-09-23-grupos-na-inbox-design.md`. `fn_comando_da_conversa` ganha `p_is_group` (grupo sem dono é `aguardando`, nunca `automatico`); o índice `uq_contacts_grupo` só é recriado quando está na definição antiga. LGPD: o gatilho da virada de `is_anonymized` (`fn_redigir_conversas_ao_anonimizar`, 0391) passa a redigir também as mensagens de GRUPO escritas por quem já é contato — casadas em `metadata.group_sender` pelo telefone (`fn_telefone_variantes`) ou pelo lid (`contacts.wa_lid`), lidos de OLD —, enfileirando a mídia; o participante que não é contato segue sem caminho (ver a spec). Cascata de anonimização: `fn_lgpd_cascade_redact_contact` parte do corpo da `0477` (propostas) e só acrescenta o passo de `channel_session_groups.subject` — por isso vem depois dela. |
| `20260928150200` | `0480_honorarios_modulo_oficial` | **Honorários, o primeiro módulo opcional no formato da ADR-0002 (contribuição de @nsbastosconsultoria, #1578).** Cria só FUNÇÕES: `fn_honorarios_provisionar()` (sem parâmetro, `security definer`, só `service_role` executa) guarda o schema de `honorarios_contratos` e `honorarios_parcelas`, que só nascem quando o administrador da instalação instala o módulo em `/admin/modulos` (`fn_modulo_instalar`); quem não instala não carrega tabela nenhuma. `fn_honorarios_parcela_pagar` paga uma parcela numa transação só, com `for update` — um clique duplo não lança o dinheiro duas vezes — e recusa conta ou plano de contas de outra organização. Revoga `execute` de `public, anon` nas duas. RLS por operação (molde da 0464): leitura para a organização; criar, editar e apagar só `manager`+; parcela paga não se apaga nem se reescreve pela sessão, nem o contrato que a tem, e a parcela só aponta para contrato da própria organização. |
| `20260928150300` | `0481_indice_cooldown_de_silencio` | **Índice para a consulta de cooldown do gatilho de silêncio (revisão do PR de correção do loop de reinscrição).** A consulta nova `loadContactIdsEmCooldown` (`lib/followup/silence-sweep.ts`) filtra `followup_enrollments` por `(organization_id, pointer_id, contact_id, updated_at)` a cada tick do cron de follow-up (1×/min); o único índice existente na tabela para `(organization_id, contact_id)` não cobre `pointer_id`. Índice composto `idx_followup_enrollments_pointer_contact_cooldown`, idempotente (`create index if not exists`), sem coluna nova. |
| `20260928184045` | `0483_anexo_na_nota_interna` | **A nota interna ganha anexo (#1863, F3): a análise de bloqueio media que sem coluna não havia por onde gravar o arquivo e sem bucket próprio ele caía no varredor órfão da 0435 (apagado em 1 dia).** Três colunas nullable em `conversation_notes` (`media_storage_path`/`media_mime`/`media_size_bytes bigint` — o trio de `messages`; o kind é derivado do mime no render, coluna que espelha função é coluna que diverge) + bucket `internal-media` (50 MB, `public=false`, SEM policy em `storage.objects`, como o próprio `whatsapp-media` da 0055: escrita/leitura pelo `service_role` e a rota de nota, nunca o Storage API direto). LGPD: a cascata ganha o passo 6d (redige `body`, zera o ponteiro e enfileira o arquivo com bucket `internal-media` — o passo 7 só enfileira `whatsapp-media`, e enfileirar ali apontaria a remoção para um bucket onde o arquivo não está) e `fn_enfileirar_midia_vencida` ganha o passo 2b (órfãos de `internal-media`, mesmo carência de 1 dia, contam em `v_orfas` para não mudar a chave congelada da 0435). Reaplicável: `add column if not exists`, `on conflict do update` no bucket, `create or replace`. Gate: `tests/invariants/anexo-da-nota-interna-responde-a-lgpd.test.ts`. |
| `20260928200400` | `0484_busca_humana_entra_na_telemetria` | **A busca HUMANA do Acervo entra na telemetria (F2 da #1869).** `knowledge_searches` só era preenchida pelo caminho do agente (`search-knowledge.ts:122`), então o gráfico de `/app/ai/evolution` — que conta linhas sem filtrar (`aggregate.ts:201`) — mostrava só a IA perguntando e a tela "Perguntar ao acervo" ficaria invisível na própria métrica. Colunas `author_kind text not null default 'ai' check (in ('human','ai'))` e `author_user_id uuid` (`on delete set null`): vocabulário da 0281, e o default é o backfill — toda linha existente veio do agente. **Sem check acoplando as duas colunas**, pelo mesmo motivo da 0281: quebraria o próprio `set null` e transformaria a saída de uma pessoa em erro de telemetria. `agent_id is null` NÃO serviria para distinguir (é `on delete set null` desde a 0181). Não mexe no INSERT do agente — `search-knowledge.test.ts:127` mede as posições $1..$8 dele. |
| `20260928214705` | `0485_lgpd_alcanca_secoes_de_modulo` | **D8 da ADR-0002 (issue #1114): a anonimização alcança as SEÇÕES de módulo declaradas, por SQL dinâmico protegido por `to_regclass`.** A cascata `fn_lgpd_cascade_redact_contact` continua sendo a função única do NÚCLEO e a exportação já tratava o 42P01 de módulo ausente (0480/#1578); o que faltava era o mecanismo GENÉRICO — sem ele, o próximo módulo com texto livre sobre a pessoa só ficaria alcançado se alguém lembrasse de reescrever uma função de ~400 linhas, e em LGPD o esquecimento é o modo de falha silencioso. Tabela nova `modulo_secoes_lgpd` `(modulo, tabela, ligacao, colunas, colunas_rotulo)` — PK composta, `ligacao` é predicado com `$1`/`$2` (org e contato) que vai para `execute ... using`, RLS ligada sem policy e `anon`/`authenticated`/`service_role` sem privilégio; escrita só pela migration do módulo. O `service_role` fica de fora também (diferente de `modulos_instalados`, 0340) porque o gatilho `definer` de dono `postgres` executa o `tabela` e a `ligacao` gravados ali: com o GRANT ALL do default ACL, quem tem a service key ganharia UPDATE como `postgres` em qualquer tabela, `api_audit_log` inclusive (0258). Precedente: `event_service_origins`; gate: `tests/invariants/adr-0002-d8-registro-fechado-ao-service-role.test.ts`. **Nasce VAZIA de propósito**: honorários não tem texto livre sobre a pessoa (decisão escrita na própria 0480), e não se inventa dado de LGPD para módulo que não pediu. `fn_lgpd_redigir_secoes_de_modulo()` é gatilho `after update of is_anonymized` em `contacts` — a MESMA porta das 0174/0184/0210/0391, por onde passam os DOIS caminhos de anonimização (cascata e `fn_lgpd_anonymize_contact`), então não há caminho que escape por construção. `security definer` SEM parâmetro (D4) e `revoke execute ... from public, anon, authenticated`: gatilho não precisa de grant para disparar, e sem argumento de organização a função fica fora da régua de `definer-membership-varredura` por construção. Cada seção passa por `to_regclass` ANTES de qualquer comando: tabela ausente = módulo não instalado = `continue`, sem erro, em qualquer instalação (é literalmente o que a ADR pede e o que uma cascata nomeada por tabela não pode dar). Coluna declarada que não existe levanta `modulo_secao_invalida` nomeando módulo e tabela — erro ALTO, porque silenciar seria entregar ANONIMIZAÇÃO COM SUCESSO com a pessoa legível. Reaplicável (`if not exists`, `create or replace`, `drop trigger if exists`); o apêndice entra ANTES do bloco da `VARREDURA anon` (0116), que proíbe `create function` depois dela. Gate: `tests/invariants/adr-0002-d8-secoes-de-modulo-na-cascata.test.ts` (seção de mentira que só existe ali: ausente pula, presente redige, declaração errada falha alto) e `tests/unit/adr-0002-funcao-provisionadora.test.ts`. |
| `20260929053040` | `0488_exclusao_de_contato_sem_perder_historico` | **Excluir um contato que já passou por retorno automático apaga a ficha INTEIRA (issue #1862).** O DELETE da rota falhava com `42501` e a auditoria saía com `apagados: ["messages","conversations"]` — o histórico já tinha sido apagado quando a ficha foi recusada. Duas causas, dois consertos. **(1) A guarda do gatilho:** `fn_followup_generation_write` (BEFORE em `job_queue` e `followup_enrollment_events`) recusa com `42501` qualquer escrita de quem tem `auth.uid()`, inclusive o `DELETE` que chega **em cascata** quando a ficha é apagada (as duas tabelas são `on delete cascade` de `contacts`). Primeira instrução do corpo: `if tg_op='DELETE' and pg_trigger_depth()>1 then return old; end if;` — a cascata roda DENTRO do gatilho da chave estrangeira (profundidade > 1) e passa; o `DELETE` **direto** do turno roda na profundidade 1 e continua recusado com a MESMA `followup_job_internal`, sem afrouxar nada. Escopo real: passa **qualquer** cascata de chave estrangeira, não só a da ficha — apagar inscrição (`followup_enrollments`), fluxo (`followup_flow_pointers`) ou organização também leva os eventos internos; o turno que sobra órfão falha fechado em `fn_followup_job_current`, e a profundidade não distingue cascata de `DELETE` feito por outro gatilho (hoje nenhum gatilho apaga nessas tabelas). **(2) Uma transação só:** `deleteContactHandler` (`app/api/v1/contacts/_handler.ts`) apagava `messages`, depois `conversations`, depois `contacts` em três chamadas separadas — quando a última falhava, as duas primeiras já estavam gravadas. A rota passa a chamar `fn_apagar_contato_com_historico(p_contact_id, p_organization_id)`, **`security invoker`** (a RLS de quem chama continua valendo e `organization_id` é filtro do banco, argumento + `where`; nenhum service role), que apaga as três na ordem certa do RESTRICT da #752 e **numa transação só**: ou sai tudo, ou não sai nada. A pré-checagem de vínculos (`calendar_appointments`) segue ANTES da chamada e devolve 409 sem tocar em nada; dentro da função, um `23503` de corrida desfaz a transação inteira em vez de deixar histórico órfão. Reaplicável (`create or replace` nas duas funções): no `baseline.sql` o corpo da guarda entra EDITADO NO LUGAR no bloco da 0224 e a função nova vai no apêndice ANTES da VARREDURA anon (0116). Gates: `tests/invariants/contato-com-turno-de-followup-sai-inteiro.test.ts` (a ficha inteira some numa transação só, o `DELETE` direto do turno segue recusado para `auth.uid()` e a ficha com compromisso na agenda não leva nada) e `tests/unit/contato-delete.test.ts` (a rota chama UMA função e não apaga tabela nenhuma por conta própria). |
| `20260929060000` | `0489_followup_rls_por_operacao` | **RLS por operação em `followup_enrollments` e `followup_flow_pointers` (issue #1913).** As duas tinham UMA policy `for all` com USING = membro da organização e sem papel mínimo: pelo PostgREST, qualquer membro — `viewer` inclusive — apagava ou reescrevia uma inscrição de follow-up ou o fluxo inteiro, e a cascata levava a trilha e os turnos agendados, sem rota nem audit. Até aqui a guarda `fn_followup_generation_write` barrava essa cascata por acidente quando havia turno; o PR #1912 a libera em cascata e abriria o buraco inteiro. Agora: SELECT = membro (inalterado); INSERT/UPDATE/DELETE = `manager`, que é o que todas as rotas de escrita de follow-up exigem (`requireRole("manager")`). `service_role`, as funções `security definer` e a cascata de FK não passam por RLS. Molde da 0464/0480; nenhuma função nova. Apêndice idêntico no `baseline.sql`, antes da VARREDURA anon. Invariante: `tests/invariants/followup-rls-por-operacao.test.ts`. |
| `20260929080000` | `0490_followup_trilha_e_versoes_rls_por_operacao` | **RLS por operação em `followup_enrollment_events` e `followup_flow_versions` (issue #1915, continuação da 0489).** As duas tinham UMA policy `for all` com USING = membro e sem papel mínimo: pelo PostgREST um `viewer` apagava ou reescrevia a trilha de eventos de uma inscrição e as versões publicadas de um fluxo (base do rollback). A escrita agora espelha quem escreve pela SESSÃO, medido nas rotas: trilha — SELECT membro, INSERT `manager` (cancel/pause/resume/skip/snooze gravam o evento manual com `requireRole("manager")`), UPDATE/DELETE sem policy (append-only para a equipe); versões — SELECT membro, DELETE `manager` (DELETE do fluxo), INSERT/UPDATE sem policy (a versão nasce só por `fn_publish_followup_flow_version`, `security definer` chamada com `service_role`). Motor (`service_role`, pool do worker), funções `security definer` e cascata de FK não passam por RLS. Nenhuma função nova; apêndice idêntico no `baseline.sql` antes da VARREDURA anon, e as policies antigas saem do corpo do dump. Invariante: `tests/invariants/followup-trilha-e-versoes-rls-por-operacao.test.ts`. |
| `20260929020337` | `0486_o_playbook_da_agenda_aprende_os_dois_passos` | **Playbook `agendamento` ensina os dois passos da cadeia de agenda (#1019).** O corpo semeado pela 0191 começava o meio da cadeia: mandava consultar `crm_find_free_slots` sem dizer de onde vem o `event_type_slug` que ela EXIGE, e nenhuma linha do playbook nomeava `crm_list_event_types` (medido: zero ocorrências em `supabase/`). Publica a v3 com o passo 1 (lista → `slug`), o passo 2 (horários com o `event_type_slug` da lista) e a marcação (`crm_book_appointment` com o `starts_at` devolvido), todo nome de ferramenta DENTRO de uma condição — o playbook é org-wide e não sabe quais capacidades o agente tem. Mesma forma da 0191: md5 conferido no insert, reponte SEMPRE. Catraca: `tests/unit/playbook-cita-a-ferramenta.test.ts` passa a cobrar `crm_list_event_types`. |
| `20260929115848` | `0491_dedupe_de_event_dead_atomico` | **O aviso `event_dead` não abre em dobro com dois drenos concorrentes (#880).** O dedupe era pergunta e escrita separadas: `lib/event-log/drain.ts` consulta se já existe aviso aberto e só depois insere, e o `insert … where not exists` de `insertInboxItem` (`lib/agent-engine/db/repository.ts`) não tinha índice nenhum que sustentasse a condição — o cron `event-log-drain` e o drain-loop do worker rodam `drainEventLog` no mesmo instante e os dois leem "não existe" antes de qualquer escrita. Índice único parcial `agent_inbox_event_dead_aberto_unico (organization_id, kind, title) where status = 'open' and kind = 'event_dead'`: a chave leva o TÍTULO porque as duas famílias do `event_dead` (a da IA que deixou de responder e a de mídia/automação, `lib/event-log/aviso-de-evento-morto.ts`) precisam conviver abertas na mesma organização, e o predicado é PARCIAL porque `status` na chave guardaria uma linha resolvida para sempre e mataria a reabertura. Escopo só `event_dead` — os outros dedupes desta tabela são por ref e querem várias linhas abertas com o mesmo título, uma por conversa ou por lead. Prévia resolve as cópias abertas repetidas (só a mais antiga fica aberta; nunca delete, régua da 0064). `insertInboxItem` e o dreno passam a tratar `23505` como "já havia um aviso aberto" — devolve `null` e não loga erro —, e a reabertura para um slot que já tem um aberto igual volta `409` em vez de `500` (`app/api/v1/ai/inbox/[id]/route.ts`). Idempotente nas duas pontas. Gate: `tests/unit/aviso-event-dead-concorrente-abre-uma-vez.test.ts`. |
| `20260930010300` | `0494_lgpd_cascata_banco_alcanca_notas_itens` | **A cascata do BANCO alcança `lead_notes`, `ai_agent_runs.tool_calls`, `lead_state` e `contacts.social_identity` (issue #1964, follow-up do #1958).** O gatilho da virada de `is_anonymized` (desenho da 0391, a porta que os DOIS caminhos de anonimização cruzam) passou a redigir também: `lead_notes.headline`/`body` (e `embedding`) → `(anonimizado)`, `ai_agent_runs.tool_calls` → redação que PRESERVA o nome da ferramenta e apaga argumentos/resultados (espelha `redigirToolCalls`/`serialize.ts`, com `redacted = true` em todo passo), `lead_state.next_action` → `null` e `qualification` → `{}`, e `contacts.social_identity` → `null` (o índice único parcial `where social_identity is not null` torna anular seguro). Filtra organização **e** contato em todo passo; idempotente — guard no WHERE repete o marcador de "já redigido" da app para a varredura diária não reescrever o que já está anonimizado. `fn_lgpd_redigir_tool_calls(jsonb)` é função nova `immutable` revogada de `anon`/`authenticated` (único chamador é o gatilho, `security definer` de dono postgres). Sem cura retroativa no corpo (a varredura diária da app já cura o que veio antes); apêndice idempotente no `baseline.sql` antes da VARREDURA anon. Gate: `tests/invariants/lgpd-cascata-do-banco-alcanca-notas-itens.test.ts`. |
| `20260930120000` | `0495_janela_de_resposta_separada` | **A janela de RESPOSTA sai da janela de DISPARO (#1983, de @suporteubere99-coder).** As duas coisas eram regidas por UM par de horas em `channel_knobs` (`window_start_hour`/`window_end_hour`, da 0010), então abrir o atendimento para 24h abria junto o disparo em massa, a prospecção e o `followup_turn` (retomada de conversa parada). Duas colunas nullable, `resposta_start_hour`/`resposta_end_hour`, sem DEFAULT: **NULL herda a janela de disparo, coluna a coluna** (`loadChannelKnobs` em `lib/agent-engine/pacing/store.ts` e `effectiveKnobs` em `lib/ai/pacing-knobs.ts`, a mesma regra que a ficha Anti-ban mostra no placeholder) — quem só atualiza não muda de operação. CHECK 0..23 / 1..24 com `drop … if exists` antes do `add`. Um bloco `do` guardado renomeia `reengajar_start_hour`/`reengajar_end_hour` para o nome novo onde a primeira versão do PR (nome antigo) já tinha sido aplicada. A separação vive em `PacingInput.resposta` (omitido = disparo: todo chamador fora do turno de resposta fica na janela de disparo); só `inbound_turn`/`case_reply_turn` passam `true` (`eTurnoDeResposta` em `inbound-turn.ts`), inclusive na pergunta do roteiro que sai no mesmo turno. `nextDayOpen` segue na janela de disparo: cap diário é proteção anti-ban. Cap, warm-up e throttle continuam para os dois lados. Sem função nova. Gates: `lib/agent-engine/pacing/janela-de-resposta.test.ts` e `tests/unit/protecao-de-envio-aceita-data-em-branco.test.ts` (0/24 aceito, 22/7 recusado com 422). |
| `20260930140000` | `0497_lgpd_anonimizar_apaga_transcricao` | **Anonimizar apaga também a TRANSCRIÇÃO da mídia (`messages.media_derived_text`).** Achado na triagem do #1988 (@AlecLimaDev). A coluna (0058, gravada pelo `media-derive-worker`) não era zerada por nenhum caminho: o body do áudio virava `[mensagem anonimizada]` e a transcrição continuava legível para o agente. Uma linha `media_derived_text = null` em cada UPDATE de messages de `fn_redigir_conversas_ao_anonimizar` (corpo da 0494, 2 passadas) e de `fn_lgpd_cascade_redact_contact` (corpo da 0483, passo 3) — nenhuma outra linha muda. Cura idempotente das mensagens já anonimizadas pelo marcador do body (alcança grupos e poupa quem voltou). A varredura diária de `lib/lgpd/cascata.ts` ganha o mesmo passo, e fecha a corrida do worker que grava o texto depois da anonimização. |
| `20260930150000` | `0498_debounce_rajada_configuravel` | **A janela de rajada do WhatsApp vira campo da versão do agente (#1856).** A janela que junta mensagens do MESMO contato numa resposta só era só a env do worker `INBOUND_DEBOUNCE_MS` (global, invisível na tela, exige VPS). Coluna `ai_agent_versions.inbound_debounce_ms` (integer, sem DEFAULT): NULL = usa a env da instalação (regressão zero para quem só atualiza), 0 desliga a coalescência, 1..60000 define a janela em ms com TETO de 60s — o teto é reforçado no leitor do worker (`debounceEfetivo` em `lib/agent-engine/edge/crm/debounce.ts`) e com CHECK `0..60000` no banco (par drop/add pra reaplicar). O drain lê a config do agente da conversa (`loadConversationAgentConfig`) e usa o valor do agente no lugar do literal. Gate: `lib/agent-engine/edge/crm/debounce.test.ts` (default = env, campo = valor, teto clamp no limite). |
| `20260930160000` | `0499_atraso_humano_por_conexao` | **O atraso humano vira configuração por conexão (issue #653).** Os quatro números que governam o atraso ANTES da primeira bolha (`atraso_notar_ms`/`ms_por_caractere`/`atraso_minimo_ms`/`atraso_maximo_ms`) saem do literal em `atraso-humano.ts` e passam a ser 4 colunas NULL em `channel_knobs` (a MESMA tabela dos knobs de pacing por conexão — 0010 e 0495; nada de tabela nova). NULL = default conservador em `defaults.ts` (900/22/1200/7500 — os valores de antes: regressão zero). O jitter entre bolhas (`1200 + rand*800` em `inbound-turn.ts`) NÃO ganha coluna: ele já é `throttle_ms` + `jitter_max_ms` (defaults 1200/800) e passa apenas a LER os knobs da conexão em vez do literal. CHECK de sanidade (`atraso_maximo_ms >= atraso_minimo_ms`, teto intervalMaxMs); exposição na ficha Anti-ban via `POST/GET /api/v1/ai/pacing`. Gate: `tests/unit/atraso-humano-knobs.test.ts`. |
| `20260930180000` | `0501_org_operante_e_suspensao_tipada` | **A suspensão que suspende (spec cobrança do revendedor, PR 1).** Suspender só tirava a pessoa da tela: a rota fazia leitura, UPDATE e `event_log` sem await em três passos soltos, nada parava jobs `pending` nem mensagens `queued`, e `status` era gravável pelo PostgREST por qualquer platform admin (`orgs_write_platform_admin` aceita `fn_is_platform_admin()`, que ignora o scope — um `support_readonly` reativava uma suspensa). (A) `organizations.suspended_kind` (`administrativa`/`cobranca`), backfill antes do CHECK, SEM CHECK de coerência com `status` (o `lgpd-redact-worker` troca para `redacted` sem limpar o tipo; regra de leitura em `lib/organizacao/operante.ts`) e `fn_org_operante(uuid)`, a régua SQL do predicado. (B) Gatilho `trg_organizacao_estado_so_pelo_servidor` (invoker, molde `fn_meet_stamp`): `authenticated`/`anon` não inserem organização nem mudam `status`, `suspended_*`, `redacted_at` ou `created_by` (42501). (C0) `fn_org_parada_descarta_fila` (invoker, EXECUTE revogado até de `service_role`; só as duas funções de estado a chamam): jobs `pending` → `failed`/`org_nao_operante` e, na mesma CTE, o que dependia deles — rascunho `approved` → `failed`/`org_suspensa`, link do Meet → `state failed` + aviso na Central (`fn_meet_notice`) — e o evento `turn_discarded` para a inscrição de follow-up parada num nó `action`, por `fn_followup_turno_descartado(org, job)` (C0a, invoker, só `service_role`): a mesma regra que o agent-worker e o envio inline chamam quando o turno JÁ RODAVA na suspensão e o envio foi barrado (`OrgNaoOperanteError`), para a reativação enfileirar um turno novo em vez de matar a inscrição com `action_turn_never_completed`. (C) `fn_suspender_organizacao` (definer, uma transação, `for update`): chama a C0, mensagens `queued` → `failed`/`org_suspensa`, `tenant.suspended` no `event_log` na mesma transação; a administrativa prevalece. (D) kind `org_reativada` no bloco único de `agent_inbox_items_kind_check` (lista completa do baseline, `kind-check-migration-x-baseline`). (E) `fn_reativar_organizacao` (definer): exige o tipo (suspensão nula vale como administrativa), zera a suspensão, falha `pending` remanescente e abre UM item `org_reativada` com a contagem de conversas (fora grupos) com mensagem durante a suspensão — nada é reprocessado. (F) `fn_claim_due_followup_enrollments` (definição vigente da 0308 + `exists` de org `active` na CTE `orgs`): o motor de follow-up não reclama inscrição de org parada nem toca o lease; na reativação ela RETOMA, e a de nó `action` com `turn_discarded` ganha turno novo em vez de esgotar o dead-man. (G) `fn_followup_generation_write` (definição vigente da 0488): recusa `turn_discarded` vindo da sessão (`auth.uid()`, 42501 `followup_step_internal`) — só o servidor grava o evento que faz o motor enfileirar. Nenhuma das duas cita `cobranca_assinaturas` (PR 2). EXECUTE revogado de `public`, `anon` e `authenticated`; só `service_role`. Apêndice antes da VARREDURA anon (seção D editada no lugar). Gate: `tests/invariants/org-suspensa.test.ts`, `tests/invariants/followup-org-suspensa.test.ts` e o par `organizations.suspended_kind` × `TIPOS_DE_SUSPENSAO` em `tests/invariants/vocabulario-banco-x-typescript.test.ts`. |
| `20260930170000` | `0500_avisos_do_jev_na_central` | **Os avisos do Jev na Central (onda 3 do Jev, bloco 3.2 — "Avisar a equipe").** Quando a empresa põe "Perceber pedido para falar com uma pessoa" ou "…para parar de receber mensagens" em **Avisar a equipe**, o Jev abre UM aviso na Central por conversa e pedido onde percebeu o pedido que a regra de hoje não pegou — e só isso: nunca passa a conversa, nunca cala o agente, nunca bloqueia, nunca responde. Três partes. (1) `jev_pedido_de_humano` e `jev_parar_de_receber` no CHECK de `agent_inbox_items.kind`, com a LISTA INTEIRA derivada do `baseline.sql` (bloco único, o da 0105). Kind próprio e não `other`: a Central dá rótulo e destino por kind, e o `other` não leva a uma conversa. (2) **Um aviso por conversa e pedido é do banco:** índice único parcial `agent_inbox_jev_pedido_unico` em (organization_id, kind, ref_id) para os dois kinds, SEM status (precedente: `agent_inbox_routing_unique`); o gravador faz o insert e, no 23505, REABRE o aviso que existe — a busca antes da escrita deixava dois drenos abrirem dois. Antes do índice, os repetidos saem (fica o aberto, e o mais novo). (3) **O aviso fecha quando o pedido foi atendido, por qualquer caminho:** `trg_fechar_avisos_do_jev_da_conversa` (em `conversations`, `update of assigned_to_user_id,status,bot_silenced_until,last_handoff_at`) fecha os dois quando a conversa sai dos estados abertos (encerrada), e SÓ o de falar com uma pessoa quando ela fica com uma pessoa — alguém assume, ou ela é PASSADA: `last_handoff_at` novo ou o robô calado (`bot_silenced_until` no futuro), o que `performHumanHandoff`, o orquestrador do clima e a pausa manual gravam (a pausa manual nem toca no status, e o gatilho de atribuição da 0228 não a veria). O de parar de receber NÃO fecha ao assumir nem na passagem: o texto dele pede que a equipe assuma E peça ao cliente o PARAR, e fechá-lo no primeiro passo sumiria com o lembrete de um pedido de descadastro antes do passo que o atende; `trg_fechar_aviso_do_jev_ao_bloquear` (em `contacts`, quando `is_blocked` passa a true — o único escritor é o STOP do próprio cliente, `lib/channels/pos-entrada.ts`; não há bloqueio à mão no produto) fecha o de parar de receber de todas as conversas do contato. Funções `security definer`, sem HTTP, filtrando a organização da própria linha, revogadas de public/anon/authenticated. O corpo do aviso NÃO leva a frase do cliente: a Central é lida por toda a organização, e a conversa só por quem a enxerga. Aditiva e idempotente. Apêndice no `baseline.sql` antes da varredura de anon (texto idêntico às partes 2 e 3); o CHECK no bloco único. Guardado por `tests/invariants/jev-aviso-na-central.test.ts` (CHECK, assumir e encerrar), `tests/invariants/jev-aviso-fecha-sozinho-e-e-unico.test.ts` (a passagem, o bloqueio, o índice único e a deduplicação) e `tests/invariants/jev-parar-de-receber-sobrevive-a-assumir.test.ts` (assumir e passar não fecham o de parar de receber; encerrar fecha). |
| `20261007131321` | `0585_indices_do_caminho_quente` | **Índices do caminho quente do agente e da poda diária da fila** (auditoria de custo e latência da IA). Cinco buscas sem índice que as servisse, todas crescendo com o uso: `send_ledger (organization_id, contact_id) where status in ('accepted','queued')` serve o "1º outbound" (`countPriorAcceptedSends`, contado sob o lock do número) e o `ultimaInboundJaRespondida` de cada turno — `status = 'accepted'` implica o predicado, então um índice atende os dois; `llm_calls (job_id)`, `lead_checkpoints (job_id)` e `lead_state_transitions (job_id)`, todos `where job_id is not null`, servem o `recordRunMetrics` e o `on delete set null` vindo de `job_queue`, que fazia a poda diária varrer cada tabela uma vez por job apagado; `event_log (event_type) where status = 'processing'` serve os dois reapers de evento preso (drain do agente e dreno geral). A chave do último é `event_type` e não `updated_at`, porque o `trg_event_log_touch` reescreve updated_at em todo update e indexá-lo tiraria o HOT update da tabela. Sem `CONCURRENTLY` (o runner usa transação; no baseline, um build concorrente que falha deixaria índice inválido que o `if not exists` pula para sempre): o update trava escrita nessas tabelas enquanto constrói cada índice. Sem função nova. Fora de propósito: índice em `metadata->>'idempotency_key'` — a busca certa é pela PK (`messages.id` = chave de idempotência), e o caminho de envio passa a fazê-la assim no PR `perf/banco-envio-sem-varredura`. A troca de status de um envio deixa de ser HOT update por causa do predicado em `status` (custo aceito). |
| `20261001120300` | `0503_trigger_imutavel_cobre_flags_e_debounce` | **A trava de imutabilidade da versão publicada passa a cobrir as duas colunas que ficaram de fora (issue #2003).** `fn_ai_agent_version_content_immutable` impede UPDATE de conteúdo em versão não-draft; a lista é escrita à mão e, desde o último create or replace (knowledge_source_ids), faltavam `proposal_ai_draft_enabled` (flag por-agente que a tela edita) e `inbound_debounce_ms` (janela de rajada, 0498 / PR #1997). A app devolve 409 mas é a única cerca; a trigger é a segunda (defense-in-depth), aberta para a service key. `create or replace` idempotente, trigger recriado; o apêndice do baseline entra ANTES da varredura de anon. Invariante: `tests/unit/trigger-imutavel-cobre-todas-as-colunas-de-conteudo.test.ts` vare as colunas da ÚLTIMA definição da trigger contra o conjunto de conteúdo da versão — a próxima coluna não repete o esquecimento. |
| `20261002190000` | `0514_recusa_permanente_do_atendimento_nao_pede_repeticao` | **A recusa permanente de `fn_service_status` passa de `40001` para `PT409`.** `service_stale` (a revisão que a tela mandou não bate mais) não melhora com nova tentativa, mas `40001` promete o contrário, e o PostgREST o devolve como HTTP 500 e a requisição é reexecutada sem fim. Medido numa VPS com Supabase self-hosted (2026-10-02): 3 chamadas no gateway em 10 min viraram ~8.500 execuções em ~10 s no banco, 29.330 rollbacks contra 2.084 commits em 30 s e a VPS com a CPU no teto. A guarda da 0250 não alcança esse caso, porque reconhece a reexecução pelo `sb-request-id` que só o gateway da nuvem carimba. Mesma saída da 0363 (agenda): `PT409` chega como 409 e 4xx não é reexecutado. `service_contact_changed` **continua** `40001` — ali o contato mudou entre a leitura e o lock, e repetir passa. Os dois chamadores (`app/api/v1/conversations/[id]/close/route.ts` e o `PATCH` de `app/api/v1/conversations/_handler.ts`) leem `PT409` e `40001` como 409. Corpo idêntico ao da última definição, com um `errcode` trocado; `create or replace`, re-aplicável, sem tabela/coluna/dado novo. |
| `20261001120800` | `0508_platform_admin_full_so_escreve` | **Platform admin `support_readonly` não escreve pelo PostgREST (#2000).** `fn_is_platform_admin()` ignora o scope, e `orgs_write_platform_admin` (baseline) a usava — um platform admin `support_readonly` alterava colunas de exibição e `settings` de organizations com o próprio JWT. A 0501 fechou as colunas de ESTADO com gatilho; o resto ficou fora de propósito aqui. Conserto: `fn_is_platform_admin_full()` exige `scope = 'full'`; as policies de ESCRITA que usavam `fn_is_platform_admin()` passam para a `_full`, e a LEITURA (FOR SELECT) continua na versão sem `_full` — `support_readonly` segue lendo, só não escreve. Onde a única policy era `FOR ALL` (api_tokens, nuvemshop_products, incidents, ai_invocations, contacts, channel_session_warmup), o par virou `_read` (SELECT) + `_write` (`_full`). Organizations ia mais: `orgs_write_platform_admin` inteira vira `_full`. Função idêntica à `fn_is_platform_admin`, com o segundo termo `scope = 'full'`; grants espelham o bloco da `fn_is_platform_admin`. Invariante em Postgres real (`tests/invariants/platform-admin-full-so-escreve.test.ts`): JWT `support_readonly` → 0 linhas alteradas em organizations/settings; `full` → continua escrevendo. Cura idempotente (drop+create) no apêndice do baseline, DEPOIS da varredura anon (só `revoke`/`grant` a `authenticated`, nenhuma `create function` nem grant a `anon` ali — a função nasce no corpo do dump) e antes das travas do suporte (0274), que seguem como último bloco. Alcance: as 15 políticas de escrita do trecho do dump; as de escrita com `fn_is_platform_admin()` criadas nos apêndices seguem para uma issue de continuação (Refs #2000). Gate: `tests/invariants/platform-admin-full-so-escreve.test.ts`. |
| `20261007131331` | `0586_uso_de_ia_agregado_no_banco` | **A tela "Uso de IA" agrega no banco (`fn_uso_de_ia`), não em JS sobre uma amostra.** A rota `/api/v1/ai/usage` buscava as linhas de `llm_calls` e somava em TypeScript; o PostgREST corta em `max_rows` (1000) e a ordem era ascendente, então passada a milésima chamada do período os dias MAIS RECENTES sumiam — e o denominador da taxa de passagem (`messages` inbound) cortava igual. A função devolve um `jsonb` (totais, por dia UTC e por purpose: custo, tokens, cache_read/cache_write, p50/p95 por `percentile_disc`, turnos do agente = jobs distintos com `agent_turn` de status `ok` e o custo do job inteiro, só nos totais e sem os filtros de agente/purpose sobre esse custo, mais inbounds e `ai.handoff_triggered` por dia). `security invoker` — a RLS isola, `p_org` é defesa em profundidade; revogada de `public, anon`, concedida a `authenticated, service_role`. Não substitui `fn_gasto_de_ia_do_mes` (régua do teto mensal). Gate: `tests/invariants/uso-de-ia-agregado-no-banco.test.ts`. |
| `20261007131344` | `0587_retencao_das_tabelas_da_ia` | **As tabelas append-only da IA ganham prazo.** Medido antes: zero `delete` em `llm_calls`, `metrics`, `skill_activations`, `ai_router_decisions`, `pacing_ledger`, `outbound_copies` e `lead_checkpoints` — todas cresciam desde a instalação. Quatro funções no molde da 0167 (`security definer`, SEM seletor de linha — só `p_retencao_dias`/`p_limite` —, piso no CORPO, lote com teto 10000, revogadas de `public`, `anon` e `authenticated`, só `service_role`), drenadas pelo cron `data-retention`, que registra `retention.sweep_run`: `fn_expurgar_telemetria_de_ia_vencida` (as quatro de telemetria, 400/100; NUNCA a cópia legada com `legacy_invocation_id`, que o backfill da 0130 ressuscitaria a cada `update.sh`), `fn_expurgar_ritmo_de_envio_vencido` (2/2; a última linha de cada número fica — é dela que o espaçamento mede), `fn_expurgar_copias_enviadas_vencidas` (30/7; as últimas `greatest(20, spinning_knobs.windowSize)` do número ficam — a janela de `loadRecentCopies`) e `fn_expurgar_checkpoints_superados` (180/30; só sai o que não é o último da fronteira `(org, contato, conversation_id, service_revision, demanda_id, demanda_revision)` de `latestCheckpoint` e não é lido por job `pending`/`running` — pelo `job_id` ou pelo `origin_job_id` do Operador). Índices de poda com nome próprio pelo relógio de cada tabela. `event_log` fica FORA de propósito: é o livro-razão de idempotência dos gatilhos de tempo. Knobs `AI_TELEMETRY_RETENTION_DAYS`, `PACING_LEDGER_RETENTION_DAYS`, `OUTBOUND_COPIES_RETENTION_DAYS`, `LEAD_CHECKPOINT_RETENTION_DAYS` em `lib/retencao/politica.ts`. Aditiva e idempotente. Apêndice no `baseline.sql` antes da varredura de anon. |
| `20261002220137` | `0530_classificador_do_roteador_automatico` | **O classificador do roteador nasce "Automático" em vez de um id fixo do Anthropic (PR #2134, @dilneiss).** `ai_routers.config` semeava `'classifier_model', 'claude-haiku-4-5'` (0085). Numa organização configurada na **OpenRouter**, esse id entrava pela PRECEDÊNCIA 3 de `decidirBinding` (`lib/ai/pontos/resolver.ts`: modelo do call site vence o padrão da organização) e ia para o endpoint da OpenRouter sem tradução — medido pelo autor numa instalação real em 2026-10-02 em `llm_calls`: `provider openrouter`, `model claude-haiku-4-5`, `http_status 400`, `"claude-haiku-4-5 is not a valid model ID"`, três vezes; o classificador nunca respondia e TODO turno caía no fallback (`intent-classifier.ts` engole a falha de propósito). Na OpenRouter o id é `anthropic/claude-haiku-4.5`, com PONTO. Quem tem binding do painel para `intent_router` não foi afetado (precedência 2). Duas partes: (a) o default passa a `jsonb_build_object('sticky', true, 'min_confidence', 0.6)` — só `classifier_model` sai; `sticky`/`min_confidence` ficam com os valores da 0085; (b) `update ... set config = r.config - 'classifier_model'` **estreita**, só quando as três coisas valem: o modelo é EXATAMENTE `claude-haiku-4-5` (o único id semeado; `anthropic/claude-haiku-4-5` é um modelo válido da Requesty, 0410, oferecido pela tela com `classifier_provider = 'requesty'`, e fica), `classifier_provider` está ausente ou vazio (a tela grava os dois juntos; o seed nunca teve provedor) e o provedor efetivo da organização NÃO é Anthropic (mesma regra de `llmSettingsSchema` em `lib/agent-engine/edge/llm/credentials.ts`: ausente, não-texto ou vazio vale `'anthropic'`). Na organização Anthropic o alias resolve (0104) e o seed funcionava: ela continua no Haiku, sem troca silenciosa para o modelo padrão. Resíduo declarado: organização Anthropic que depois mude de provedor leva o seed junto (já era assim); salvar em "Automático" limpa. Sem coluna, sem função, sem RLS nova. Idempotente. Apêndice com o mesmo texto no `baseline.sql`, antes da VARREDURA anon (0116). Gates: `tests/unit/router-config-classificador-automatico.test.ts` (texto do default e da cura, nos dois arquivos) e `tests/invariants/classificador-do-roteador-automatico.test.ts` (executa o UPDATE do próprio apêndice contra organizações OpenRouter, Anthropic sem provedor gravado e Requesty). |
| `20261002151300` | `0513_motivo_de_perda_pediu_para_nao_receber` | **Motivo de perda próprio para quem responde PARAR (PR #2049, de @paulolimajr77; decisão do dono, doc 85, opção B).** A ingestão passa a fechar sozinha todo negócio aberto do contato que pediu para não receber mensagens, e o motivo `requested_by_customer` ("Cliente solicitou cancelamento") dizia algo que o cliente não pediu. Novo canônico `opted_out_of_messages` ("Pediu para não receber mensagens") no array de `fn_validate_lost_reason_required` — sem ele o trigger recusaria a perda com 22023 `lost_reason_invalid`. CONTA como perda: `fn_attendant_metrics`/`fn_atrito_metrics` não mudam (só excluem a transferência, 0266). `create or replace` com corpo idêntico ao da 0426 mais o motivo; assinatura e grants inalterados, `revoke`/`grant` repetidos. Corpo EDITADO NO LUGAR na ÚLTIMA definição do `baseline.sql` (mesmo desenho da 0426). Gate: `tests/unit/motivo-de-perda-de-quem-pediu-para-parar.test.ts` (código e trigger com o mesmo vocabulário). |
| `20261002201500` | `0521_avisos_do_security_advisor` | **Os dois avisos reais do Security Advisor do Supabase.** (1) `function_search_path_mutable`: as 7 funções de public sem `search_path` fixo (medido no baseline em Postgres 17: `fn_agent_versions_immutable`, `fn_ai_agent_version_content_immutable`, `fn_contato_anonimizado_limpa_campos_personalizados`, `fn_degraus_de_lembrete_validos`, `fn_corpos_de_lembrete_validos`, `fn_lancamento_pago_e_imutavel`, `fn_resolve_inbound_number`) ganham `search_path = ''` — nenhuma lê tabela sem schema, então o comportamento não muda; a que importa é `fn_resolve_inbound_number`, SECURITY DEFINER. (2) Definer chamável por `authenticated` que o código quer só no servidor: `fn_resolve_inbound_number(text)` (a 0347 revogou public/anon, mas o DEFAULT PRIVILEGES deixou authenticated; único call site é o worker de voz com admin client — um usuário logado descobria org e agente de qualquer número) e `rls_auto_enable()` (fora do baseline desde 2026-08-27, mas o Supabase a cria em projeto novo com EXECUTE para authenticated; bloco `do` só age se existir). Só ALTER/REVOKE, idempotente; apêndice no fim do `baseline.sql`. Medido: depois dela, 0 funções de public sem search_path e as duas só executáveis por `service_role`. |
| `20261002213953` | `0525_auditoria_so_inclusao_para_todo_papel` | **O audit log é só-inclusão para todo papel que não seja o dono.** A 0258 revogou UPDATE, DELETE e TRUNCATE de `api_audit_log` numa lista fixa (public, anon, authenticated, service_role); o papel dedicado do worker que `docs/deploy-selfhost/README.md` manda criar (`agent_worker`, DML em todas as tabelas de `public`) ficava fora da lista. Bloco `do` sem função nova: todo grantee com grant direto de UPDATE/DELETE/TRUNCATE na tabela, exceto o dono, perde os três — com qualquer nome de papel, inclusive PUBLIC. INSERT e SELECT ficam. Idempotente; roda a cada `update.sh`, então instalação que seguiu a receita antiga se cura na atualização. O expurgo (0167, definer do dono) e as FKs `on delete set null` não dependem destes grants. A receita do guia ganhou o `revoke` explícito. Apêndice no fim do `baseline.sql`. Gate: `tests/invariants/audit-log-so-inclusao-para-todo-papel.test.ts` (receita do guia lida do README, cura de papel com outro nome e de PUBLIC, `permission denied` no DELETE, INSERT/SELECT de pé). |
| `20261004090001` | `0534_lead_reopened_vira_gatilho` | **`lead.reopened` passa a ser gatilho de verdade (PR #2211, de @webtecnica; issue #1528).** Mesmo desarme da 0417, para outro tipo: a 0239 pôs `lead.reopened` na lista de `fn_event_log_e_registro` quando ele não tinha consumidor, e com o PR ele entra em `ENTIDADE_ESPERADA_POR_GATILHO` e o `automationRulesHandler` o assina. Sem a redefinição, `fn_event_log_marca_registro` (BEFORE INSERT) o faria nascer `done`, o drain nunca o selecionaria e a regra "quando um lead encerrado for reaberto" seria salva e nunca rodaria. Medido no CI do PR antes do conserto: `tests/unit/evento-de-fato-nao-fica-pendente.test.ts` reprovou com `expected [ 'lead.reopened' ] to deeply equal []`. Os outros três gatilhos do PR (`lead.won`, `lead.lost`, `lead.assigned`) nunca estiveram na lista. **A função é redefinida sem o tipo** (`create or replace`, mesma assinatura e grants; `revoke`/`grant` repetidos). **Sem backfill de propósito**, pelo motivo da 0417: reprocessar reabertura antiga faria a regra rodar hoje por um fato de semanas atrás. **No `baseline.sql` o corpo entra por EDIÇÃO NO LUGAR do bloco ÚNICO** (mesmo desenho da 0417). Gate: `tests/unit/evento-de-fato-nao-fica-pendente.test.ts`. |
| `20261007130926` | `0583_cobranca_planos_e_assinaturas` | **Cobrança do revendedor, PR 2: planos e limites.** Capacidade do NÚCLEO com chave da instalação (`platform_config.MODULO_COBRANCA`, só `ligado` liga), desligada por padrão (ADR-0004, D-1): as duas tabelas nascem VAZIAS em toda instalação e, com a chave desligada, nada do que existe muda. (A) `cobranca_planos` (da instalação, sem `organization_id`: RLS ligada sem policy, só `service_role`; preço mínimo R$ 5, moeda BRL, um plano do cadastro por vez) e `cobranca_assinaturas` (uma linha por organização, PK em `organization_id` com cascade; RLS com SELECT só do `admin` da org; escrita só do `service_role`); organização SEM linha é isenta de cobrança, limite e régua. Saem `organizations.ai_budget_cents` e `rate_limit_rps` (zero leitores). Apêndice antes da VARREDURA anon, depois do bloco da 0501. (B) `fn_cobranca_ligada()` e `fn_limite_do_plano(org, recurso)`: nulo = sem limite (chave desligada, org isenta ou plano sem teto); recurso fora de `RECURSOS_DO_PLANO` é 22023 com qualquer chave; o plano agendado não conta; EXECUTE só do `service_role`. (C) Assentos: `trg_membro_provisorio_so_pelo_servidor` (INVOKER: numa definer `current_user` é o dono; a sessão não CRIA vínculo provisório, 42501, mas revoga e reativa o existente) e `trg_trava_assentos_do_plano` (DEFINER; conta ativo não provisório; trava consultiva 2282; `PT402` com a mensagem `limite_do_plano:assentos:<teto>` e DETAIL `{recurso, limite, em_uso}`). O apêndice reafirma `provisional_until_handover`, que no baseline só nasce depois da VARREDURA. (D) Canais: `trg_trava_canais_do_plano` (conta não arquivado e não `wacalls`; regravar `archived_at = null` num canal já ativo não conta; a MESMA trava 2281 de `fn_reserve_channel_connection`; `PT402` com a mensagem `limite_do_plano:canais:<teto>`). (E) `trg_trial_na_criacao_da_org` (AFTER INSERT: chave ligada + plano do cadastro vigente + autor que não é platform admin ativo → `trial` com os dias do plano) e `fn_create_tenant_with_owner` com `plano_id` (chave desligada → 22023 `cobranca_desligada`; plano arquivado ou inexistente → 22023 `plano_invalido`; `settings.plan` segue igual). (F) `fn_suspender_organizacao` devolve `org_isenta` ao pedido de cobrança sobre org ativa sem assinatura; `fn_reativar_organizacao` ganha o passo 7 (zera `ultimo_aviso`/`ultimo_aviso_em`) e passa a contar, no aviso `org_reativada` e no `tenant.reactivated` (`agendamentos_desligados`, `passos_descartados`), os agendamentos de disparo único que o scheduler desligou durante a suspensão e os turnos de follow-up falhados sem `turn_discarded` (acabamento 22 da PR 1); `fn_cobranca_liberar_suspensoes(p_ator)` reativa, pela mesma porta, toda org `suspended{cobranca}` quando a chave é desligada. Os dois corpos partem da 0501 vigente; `org-suspensa.test.ts` ganha uma assinatura para a org que ele suspende por cobrança e `followup-org-suspensa.test.ts` um caso (o turno de envio com evento não entra no aviso). (G) O aviso do teto do PLANO (`budget_exceeded` com `ref_kind = 'plano'`) e o do orçamento da org deixam de se calar: o índice `agent_inbox_budget_aberto_unico` da 0540 passa a excluir `ref_kind = 'plano'` (troca só quando a definição ainda é a antiga) e nasce `agent_inbox_budget_do_plano_aberto_unico` (um aberto por org). No baseline, o bloco da 0540 ganha a terceira chave na partição da passada de dedupe — senão o `update.sh` resolveria o aviso do plano a cada atualização —, e a seção G mora num bloco próprio no FIM do arquivo, depois do da 0540; gate `tests/invariants/cobranca-aviso-do-plano-e-do-orcamento.test.ts`. Gate da 0583: `tests/invariants/cobranca-suspensao-e-liberacao.test.ts`, `tests/invariants/followup-org-suspensa.test.ts`, `tests/invariants/cobranca-trial-na-criacao.test.ts`, `tests/invariants/cobranca-canais.test.ts`, `tests/invariants/cobranca-assentos.test.ts`, `tests/invariants/cobranca-limite-do-plano.test.ts`, `tests/invariants/cobranca-tabelas.test.ts`, `tests/invariants/cobranca-isolamento.test.ts` e os pares de `lib/cobranca/vocabulario.ts` em `tests/invariants/vocabulario-banco-x-typescript.test.ts`. |
