277 KiB
Migration Manifest — DeskcommCRM
Migrations applied to Supabase project rrydmwnporysaiysiztn (sa-east-1, Postgres 17) via Supabase MCP on 2026-04-28.
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:
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 |
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 |
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 |
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 |
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/dispatcher/budget.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. |
| 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. |
| 20260911160000 | 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. |
| 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/trava-de-definer-tem-prazo.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. |
| 20260915230000 | 0261_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. |
| 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. |
| 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). |
| 20260916010000 | 0262_prospeccao_nativa | Busca com orçamento, candidatos únicos por organização e campanhas graduais com pausa e histórico; acesso exclusivo pelo servidor autenticado. |
| 20260916020000 | 0263_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. |