511 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/budget/check.ts lê para decidir throttle — a IA do clone podia parar sem nenhuma chamada nova. O conserto não é disable trigger: isso deixaria a peça do recomputo de pé e, pior, um update.sh que morresse entre o disable e o enable deixaria a instalação sem contador nenhum, em silêncio. Aqui a última palavra é um recomputo que roda DEPOIS do backfill e atribui (não incrementa) o gasto real do mês, contando cada linha uma vez (not exists legacy_invocation_id) — vale qualquer que tenha sido o estado deixado pelo trigger, e re-aplicar chega no mesmo número. Muda de comportamento, declarado: o total do mês passa a incluir o que os workers legados gastaram; esse dinheiro sempre saiu, só não era contado. tests/invariants/orcamento-apos-backfill.test.ts fixa a propriedade executando os DOIS blocos extraídos do baseline (não reimplementados) — sabotar o not exists dá 4 vermelhos, como previsto. |
20260808040000 |
0139_kind_check_completo |
Forward-fix da 0129, que encolheu o vocabulário de agent_inbox_items.kind. A 0129 reconstruiu agent_inbox_items_kind_check com os 15 valores que conhecia enquanto o vigente já tinha 18, apagando contact_proposal_expired (criado pela 0124, a migration IMEDIATAMENTE anterior), promise_unfulfilled e other. O dano não é cosmético e é mudo: em quem aplica migrations em ordem (supabase db push), os INSERTs de lib/contacts/proposta-de-dado.ts (dado ouvido que venceu sem alguém conferir) e de lib/agent-engine/agent/operator-turn.ts (promessa não cumprida) passam a violar a constraint, o catch fire-and-forget engole, e o operador nunca vê o aviso — o mesmo desfecho que a 0129 existia para evitar, por outra porta. Quem usa o kit (install.sh/update.sh) NÃO foi afetado: o kit aplica só o baseline.sql, cujo bloco único sempre teve os 18 — por isso esta migration não tem apêndice novo no baseline, e acrescentar um segundo bloco reconstruindo a mesma constraint seria o defeito da issue #159 que baseline-constraint-reconstruida.test.ts proíbe. A classe, e não só a instância: tests/unit/kind-check-migration-x-baseline.test.ts passa a exigir que a ÚLTIMA migration que reconstrói a constraint bata valor a valor com o baseline — a comparação que nunca existiu, porque pnpm test:db aplica só o baseline, que estava certo. |
20260807060000 |
0122_telefone_do_lid |
O telefone do contato @lid, que sempre chegou e nunca foi lido. Medido em webhook_events_log da produção: 76 de 76 payloads @lid trazem o número em _data.key.remoteJidAlt — a leitura de que "@lid é opaco" valia para o from, não para o payload. Gravar esse telefone, porém, mudava a wa_identity GERADA de lid: para phone:, quebrava o reencontro pelo on conflict e duplicava o contato (o defeito que a 0027 matou). Por isso entra contacts.wa_lid: correlação do WhatsApp que não depende do telefone, com índice único e dedup auto-curativa ANTES da constraint. fn_upsert_wa_contact ganha o 7º parâmetro e passa a COMPLETAR o que falta em vez de só congelar o nome (a versão de 6 params é dropada). Inclui o backfill dos display_name técnicos legados (Contato 543134@lid), com is_anonymized = false para não reverter anonimização. |
20260807070000 |
0123_fila_de_confirmacao_de_dado |
O dado que o cliente diz na conversa espera confirmação humana (spec 17 §4b). A IA propõe; uma pessoa confirma. Sem isto, um e-mail dito de brincadeira substituiria o correto e o valor antigo não existiria em lugar nenhum. Tabela contact_field_proposals com a FORMA de crm_lead_reactivations (prazo obrigatório, decisão datada, decisor) e a chave certa — contato+campo, porque a proposta é sobre a pessoa. O índice único parcial where status='pending' é a idempotência: a décima proposta do mesmo e-mail bate 23505 no banco, e não numa checagem racy. Guarda valor_anterior desde o nascimento — é o from que a L-06 exige, disponível ANTES da decisão. LGPD por TRIGGER no estado (is_anonymized virou true), não por chamada dentro do cascade: há mais de um caminho que anonimiza, e pendurar no fato cobre todos, inclusive os que ainda não existem. |
20260807080000 |
0124_kind_proposta_vencida |
A Central avisa quando um dado ouvido venceu sem alguém conferir. A 0123 criou a fila com expires_at obrigatório, e prazo que ninguém cobre é pior que nenhum: promete um limite inexistente e a pendência vira badge permanente, que SIMULA atenção. Vencer é decisão — a do relógio —, mas decisão que só acontece no banco é invisível. Kind contact_proposal_expired, severidade info (nada quebrou; tratar como falha ensinaria a ignorar os avisos que são falha). Entra na lista EXISTENTE, no bloco único que reconstrói a constraint (#159). |
20260807090000 |
0125_escopo_de_funil_do_agente |
O agente só opera nos funis marcados (spec 17 passo 3). Medido: uma organização com 4 funis e 5 agentes de negócios diferentes (um SDR, dois suportes, uma atendente de clínica) — todos alcançando todos. ai_agent_versions.pipeline_ids uuid[] default '{}': vive na VERSÃO para a permissão ser publicada junto com o resto; vazio = NENHUM funil. Backfill derivado do histórico real (os funis onde cada agente já registrou atividade): sem ele, no dia do deploy todo agente em produção pararia de mexer em card, de uma vez e em silêncio. Medido: só 1 de 8 agentes tem histórico. Traz o CONSERTO do fn_ai_agent_version_content_immutable, que parava no followup e ignorava as nove colunas posteriores — escopo de permissão editável em versão publicada é a ausência de escopo com aparência de controle. |
20260811110000 |
0150_rbac_na_config_de_ia_e_canais |
A RLS isolava a organização, mas não aplicava o PAPEL — e o requireRole() das rotas não é a única porta. Segundo achado do mesmo relatório da comunidade, e o mais consistente dele. Medido no baseline da main: das 82 policies ALL de public, 71 não citam fn_role_at_least — só tenancy, via fn_user_org_ids(), que devolve organizações e nada mais; e authenticated tem SELECT/INSERT/UPDATE/DELETE nessas tabelas. Isso importa porque o PostgREST é exposto ao browser por construção (URL + anon key vão no bundle): um usuário logado fala com ele direto, com o próprio JWT, e as rotas Next ficam de fora do caminho. Provado num pg17 com este baseline, membro papel viewer: reescreveu ai_agents.system_prompt (o texto que o bot fala com cliente real), derrubou channel_sessions (o canal de WhatsApp) e DELETOU a linha de ai_provider_credentials (mata a IA da org) — 4 de 4 ataques, com o controle mostrando que a tenancy vale (o viewer não alcança a organização vizinha). Depois: 0, 0, permission denied, permission denied, com o viewer ainda lendo (senão a tela quebra) e o admin escrevendo tudo. Escopo deliberado: só as tabelas de CONFIGURAÇÃO de IA e canais, onde o dano é inequívoco e onde a rota já exige admin (channel-sessions route.ts:61, ai/agents route.ts:67, ai/budget route.ts:46) — a policy passa a espelhar a API em vez de ficar três níveis mais frouxa. As outras ~63 ficam para depois de propósito: job_queue, llm_calls, send_ledger, metrics e afins são escritas pelo motor, e apertá-las no mesmo fôlego trocaria um furo de segurança por uma parada de produção; a lista está congelada numa allowlist no invariante, que nasce verde sobre a dívida atual e vermelho na próxima tabela nova — com um controle que reprova se a allowlist guardar tabela inexistente. O segredo cifrado sai por COLUNA, não pela tabela: ai_provider_credentials_safe é security_invoker=true de propósito (para a RLS da base valer para quem consulta), então revogar o SELECT inteiro quebraria a view e a tela — foi o controle positivo do invariante que reprovou a primeira versão desta migration. Com grant por coluna, api_key_encrypted/iv/tag ficam inalcançáveis pelo PostgREST e as doze colunas da view seguem legíveis. O par SELECT+escrita preserva or fn_is_platform_admin() onde já existia, senão o super-admin perde acesso e o suporte cega. Worker não entra na conta: usa service_role, que é bypassrls. |
20260811100000 |
0149_definer_valida_membership |
Duas SECURITY DEFINER confiavam no organization_id do ARGUMENTO, e isso é vazamento entre organizações. Veio de um relatório de segurança de um usuário, auditando a tag v1.0.0. A metade dele sobre ACL ("definer executáveis por anon") já estava fechada pela 0108 e pela 0116 — medido, 0 de 31 hoje contra 14 de 26 na v1.0.0, mesmo instrumento nos dois lados. Mas ACL e MEMBERSHIP são defeitos independentes, e é exatamente por isso que este sobreviveu com o gate verde: a varredura da 0116 (hardening-definer-varredura.test.ts) pergunta quem pode executar, nunca a função confere de qual organização é quem executou. emit_event e retrieve_top_k_chunks são — corretamente — executáveis por authenticated, têm call site com sessão de usuário, e usavam o p_organization_id recebido como único filtro de tenant; rodando como postgres, contornam a RLS. Medido num pg17 com este baseline, usuário papel viewer membro só da org A, como role authenticated com o sub dele: INSERT direto em event_log da org B → permission denied; SELECT direto em ai_chunks da org B → 0 linhas (a RLS está de pé, o furo é da função); emit_event(..., org => B) → gravou na org B, que é a fila que workers e automações consomem; retrieve_top_k_chunks(B, kbv) → devolveu o conteúdo da base de conhecimento da org B. O remédio é o guard que este schema já usa em fn_conversation_assign: if auth.uid() is not null and not fn_role_at_least(org, 'viewer'). O auth.uid() is not null é a parte que torna isto seguro sem quebrar o produto — worker, cron e trigger chamam com service_role (sem JWT, auth.uid() nulo) e passam direto, como sempre passaram; quem ganha a checagem é só a sessão de usuário. Papel mínimo viewer porque o que se barra aqui não é o papel fraco, é a organização alheia. fn_log_event delega a emit_event e herda o guard — não ganha cópia da regra. Provado nas duas direções (ataque falha e uso legítimo continua): tests/invariants/definer-valida-membership.test.ts, com controle positivo de leitura na própria org e do worker service_role — sem eles, uma função que lançasse exceção sempre deixaria a suíte verde pelo motivo errado. Cuidado ao reescrever: create or replace recusa renomear parâmetro e coluna de retorno, e os nomes reais são p_embedding/p_threshold (default 0.40) e knowledge_source_id — nomes divergentes criam sobrecarga nova e deixam a versão sem guard viva; um do $$ no bloco avisa se aparecer. |
20260811090000 |
0148_caso_anuncia_no_barramento |
O caso de escalação passa a anunciar abertura e fechamento no event_log. agent_cases é o instante em que o sistema declara "isto precisa de gente" e não emitia NADA no barramento — grep por emit_event em lib/agent-engine/, lib/escalacao/ e app/api/v1/ai/cases/ devolvia zero (controle positivo: a mesma sonda acha lib/leads/agent-stage-sync.ts:308). Nenhum consumidor podia reagir a um caso, e é isso que destrava o gatilho de follow-up por CASO ABERTO. Por TRIGGER e não por emissor em código, mesma doutrina da 0138: a abertura tem um escritor (openCase) mas o FECHAMENTO tem CINCO (provideCaseUpdate/resolveCaseFromHuman/markAwaitingLead/escalateCase/encerrarChamadoPeloAgente) — caçar emissor deixa a garantia dependendo de alguém lembrar e o próximo caminho nasce mudo; com trigger a garantia é da TABELA e vale para motor, seed, script e rota futura. Não viola o anti-pattern nº 9: a proibição é trigger fazendo HTTP, e aqui é SQL puro sem I/O, como fn_emit_conversation_routing (0040) já faz. O fechamento entra JUNTO com a abertura, e isso é desenho, não escopo extra: medido, o caso de referência fechou em 6 segundos — follow-up nascido de caso e nunca cancelado só morre por esgotamento ou resposta, e o cliente seria cobrado sobre um problema já resolvido (invariante 7 do Sistema Vivo, o laço tem de fechar). Anti-eco: o consumidor escreve em followup_enrollments e NUNCA em agent_cases, e o trigger de UPDATE é of status + old.status is distinct from new.status, então toque em updated_at/summary/followup_attempts não emite. security definer owner postgres com as DUAS origens de EXECUTE revogadas (item 9 da doutrina). Idempotente: create or replace + drop trigger if exists. |
20260810160000 |
0147_relogio_do_banco |
Quem agenda usava o relógio do processo; quem reclama usa o do banco. next_eval_at era gravado com new Date() do Node e fn_claim_due_followup_enrollments compara com now() do Postgres — medido nesta máquina, 12 amostras, o banco fica 17 a 34 ms atrás. O instante que o processo chama de "agora" ainda é FUTURO para o claim: o tick seguinte não reclama e o enrollment espera o tick DEPOIS, até 60 s com o cron de minuto em minuto. Dói mais em reactivity.ts, que marca "acorde agora" QUANDO O LEAD RESPONDE — um minuto de silêncio depois de a pessoa falar com a gente, num sistema cujo propósito é não deixar ninguém esperando; em gatilho-etapa.ts e silence-sweep.ts o efeito é o follow-up nascer e perder o primeiro tick, mesmo mecanismo e custo menor porque ninguém aguarda do outro lado. Não se corrige com margem: subtrair milissegundos do relógio do processo é número mágico que depende de máquina, carga e distância até o banco — calibrado num ambiente, defeito adormecido em outro; e afrouxar o claim para tolerar futuro próximo relaxaria a condição para todo agendamento, inclusive os legítimos no futuro. A correção é os dois lados usarem o MESMO relógio, e são duas ferramentas porque são dois casos. (1) Nascimento (INSERT): next_eval_at ganha default now() — quem agenda "para agora" OMITE a coluna e o instante nasce do banco, com zero round-trip e nada para chamar errado. Aditivo e retrocompatível: default só age na AUSÊNCIA da coluna, então todo insert que hoje passa valor explícito continua idêntico. (2) Retomada (UPDATE): default não alcança UPDATE e o supabase-js grava VALOR, nunca EXPRESSÃO (set next_eval_at = now() não é exprimível pelo client), daí fn_agora() — um round-trip, e só nos eventos que de fato acordam alguém. A função nasce com os grants revogados das DUAS origens (o alter default privileges … to anon do baseline, que revoke from public não remove, e o grant a PUBLIC que o Postgres dá na criação, que revoke from anon não remove). |
20260810122000 |
0146_claim_justo_entre_organizacoes |
O tick do follow-up servia uma organização de cada vez. fn_claim_due_followup_enrollments levava os p_limit (20) vencidos mais antigos GLOBALMENTE, e quem acumulou fila tem por construção os next_eval_at mais antigos — então uma organização atrasada ocupava o lote inteiro. Medido em pg17 descartável, teto 20: com 25 vencidos na grande e 1 na pequena, o tick 1 leva 20 da grande e zero da pequena; com 300 na grande, a pequena só é atendida no tick 16 (≈16 min, com o cron de minuto em minuto do docker-compose.prod.yml). Correção do diagnóstico que circulava: não é inanição permanente — o lease segura o lote reclamado, o ponteiro avança, e a organização pequena entra em teto(K/20) ticks, onde K são os vencidos mais antigos que ela. É atraso proporcional à fila do vizinho; continua sendo defeito porque o atraso não tem limite superior e quem o paga não tem como saber por quê. Passa a ser rodízio: o mais antigo de CADA organização, depois o segundo de cada, até fechar o teto. Com UMA organização o resultado é idêntico ao de antes (os 20 mais antigos), então a instalação de operador único — a de hoje — não muda em nada; medido: 20 reclamados, e os 5 mais novos ficam de fora. O limit p_limit DENTRO do lateral não é cosmético: sem ele o sort final ordenaria todos os vencidos do banco, e com ele o custo passa a depender do número de organizações com fila, não do tamanho dela. for update skip locked sai da subquery e vira CTE porque o Postgres não o aceita junto de window function — a propriedade (dois workers nunca pegam a mesma linha, nenhum espera pelo outro) é a mesma, aplicada ao conjunto já escolhido. Índice parcial (organization_id, next_eval_at) novo para o lateral. tests/invariants/followup-claim-justo.test.ts chama o adapter de produção (createPgAdminClient); sabotado de volta para a versão global, 2 dos 4 casos reprovam — exatamente os dois que envolvem mais de uma organização, como previsto. |
20260810121000 |
0144_followup_timing_plan |
O modo adaptativo do nó de espera existia na tela e não existia no motor. O construtor de fluxos oferece "Adaptativo (min–max)" com mínimo, máximo e um campo de orientação; node-handlers.ts fazia mode === 'fixed' ? duration_ms : max_ms — o fluxo esperava SEMPRE o máximo, e a orientação escrita pelo operador não era lida por ninguém. O decisor por LLM estava escrito (decideFollowupTiming) e sem produtor: processNode nunca devolvia enqueue_turn para um nó wait, então o ramo que carregava o guidance era código morto — o comentário no próprio arquivo admitia "SEM PRODUTOR VIVO hoje". Controle decorativo é pior que controle ausente: ele mente e some sem deixar rastro. A coluna guarda o plano decidido uma vez, no acionamento (nó trigger), para TODAS as esperas adaptativas de uma vez — estratégia sobre a sequência, não reflexo por nó. Isso também dissolve a race que travou a feature: um turno de decisão POR nó criaria um terceiro estado indistinguível dos dois que resolveWaitPhase já desempata; sem turno por nó, não há terceiro estado. E custa uma chamada de modelo por follow-up em vez de N. O modelo propõe, o nó decide: cada proposta é grampeada em [min_ms, max_ms] contra o grafo pinado, com clampado e o proposto_ms original guardados para o dossiê poder dizer "a IA pediu 3 dias, o seu limite era 12h", mais um motivo em pt-br que é o que a tela mostra ao operador. Sem CHECK e sem NOT NULL, declarado: null significa "ainda não planejado" e é o estado de todo enrollment anterior — os dois caem no comportamento antigo (o máximo), então não há dado a corrigir antes; um CHECK de shape sobre jsonb quebraria o update.sh de um clone que já tivesse gravado qualquer coisa ali, e quem valida é lib/followup/timing-plan.ts, que degrada para o máximo diante de plano ilegível em vez de derrubar o tick de todo mundo. Turno de planejamento que nunca volta não mata o enrollment: após MAX_PLAN_RECHECKS o trigger segue sem plano e grava o evento timing_plan_desistido — degradar é aceitável, perder o follow-up não. |
20260807160000 |
0116_definer_nova_nasce_exposta |
O buraco que a 0108 (issue #128) não fecha: ela revogou anon numa lista de 8 funções, medida num banco instalado do ZERO — e quem ATUALIZA tem outro estado. O ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon que o baseline carrega por paridade com o Supabase não é uma linha cujo efeito acaba quando ela passa: grava uma entrada em pg_default_acl, que fica no catálogo do banco, e a partir dali toda função criada em public nasce com EXECUTE para anon — inclusive as de toda migration futura. Numa instalação nova não incomoda (o dump cria as funções antes da linha). Medido numa VPS real em 2026-08-07, comparando com o que um install fresco produz: 7 divergências, todas permissivas — 6 definer expostas a anon (entre elas fn_decrypt_oauth, alcançável pela anon key, que vai para o browser — o mesmo desenho que a 0108 chamou de letal, reaberto pelo caminho de update) e 5 a authenticated. A cura é varredura, não segunda lista: lista conserta o estoque de hoje e reabre no próximo create function. O bloco percorre todas as security definer de public, revoga das DUAS origens (public e anon — cada revoke sozinho deixa a outra em pé, a lição da 0108) e devolve o privilégio efetivo de authenticated/service_role medido ANTES, para tirar anon sem tirar leitura de ninguém. Desfazer o ALTER DEFAULT PRIVILEGES seria tratar a origem e foi tentado primeiro — não serve: ele vem do pg_dump no CORPO do baseline e é reescrito a cada re-aplicação, então o revoke duraria até o próximo update.sh. A varredura roda no FIM do apêndice, depois de tudo que cria função, e por isso cura no mesmo run em que o defeito nasceria — posição que tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts vigia. Para authenticated a regra é nominal (5 revokes) e não varredura: ele PRECISA de EXECUTE nos helpers de RLS (a policy é avaliada com o papel de quem consulta) e em retrieve_top_k_chunks. Idempotente (revoke de privilégio ausente é no-op). |
20260810120000 |
0145_dossie_do_followup |
O follow-up deixa de ser caixa-preta, e pausar deixa de ser matar. followup_enrollment_events grava cada passo do motor desde a 0054 e NENHUMA tela lia a tabela: a fila dizia que existe um follow-up e não dizia nada do que já aconteceu nele — o dado existia e era invisível, que é a forma de falha que o invariante de log visível existe para proibir. E a única intervenção disponível era cancelar, porta de saída e não intervenção: segurar o fluxo por dois dias custava matá-lo e perder a caminhada. Três peças. (1) timing_plan jsonb — o plano de atrasos que o agente decide UMA VEZ ao acionar o follow-up (contrato fechado com a 0144, que o produz). Criado aqui com if not exists de propósito: o consumidor é o dossiê, e ele não pode depender da ORDEM em que as duas migrations chegam ao banco de um clone — coluna que falta vira 42703 e derruba a rota inteira, não o bloco que a usa. (2) o status paused_manual. paused_handoff JÁ existe e significa outra coisa ("um humano assumiu a conversa"), e quem o retoma é reactToHandoffClose quando o handoff fecha — usar aquele estado para a pausa manual faria o sistema retomar sozinho, em silêncio, um follow-up que uma pessoa mandou parar, na primeira vez que qualquer handoff daquele contato fechasse. Os dois CHECKs são dropados pelo CATÁLOGO (definição que fala de paused_handoff e ainda não conhece paused_manual), nunca por nome fixo: num clone que passou por dump/restore o nome gerado pode ser outro, o drop não acharia nada, o add tropeçaria no duplicado e o exception when duplicate_object engoliria — update.sh verde e o banco recusando o INSERT que a aplicação considera válido. (3) o índice de unicidade do vivo passa a contar a pausa: sem isso o enrollment pausado libera a vaga do par (pointer_id, contact_id) e um segundo nasce ao lado, dois follow-ups do mesmo fluxo para a mesma pessoa no instante da retomada. Nada a deduplicar antes: o CHECK só ACRESCENTA valor e o predicado novo do índice cobre exatamente as linhas do antigo (nenhum banco tem paused_manual antes desta migration). Mais um índice (enrollment_id, created_at) para a leitura do dossiê — o único que existia é PARCIAL, serve ao dedup do motor. Correção pega na integração: a primeira versão recriou o índice do vivo copiando as colunas da DDL ORIGINAL (pointer_id, contact_id) em vez das que estavam EM VIGOR (organization_id, contact_id, postas por uma migration anterior com backfill de exclusividade). Mexer só no predicado a partir da linha errada reverte a garantia — o mesmo contato voltaria a poder ter um follow-up vivo em CADA fluxo — sem conflito de merge, sem erro de aplicação e sem sintoma imediato. O invariante que devia pegar isso passava por sorte: ele criava o segundo enrollment no MESMO fluxo, e assim ficava verde com qualquer um dos dois predicados. Agora ele nasce em outro fluxo, e sabotar o baseline de volta o deixa vermelho (medido: 1 vermelho, o previsto). |
| 20260809100000 | 0142_camadas_de_seguranca_por_org | As duas verificações que custam dinheiro viram escolha da organização. A cadeia antes do envio tem dez verificações; oito são determinísticas, custam zero e não são escolha de ninguém (desligar o que respeita quem pediu para parar, ou o que impede o número do cliente de ser bloqueado, é sabotagem do negócio dele). Duas consultam um modelo e custam por mensagem — a segunda leitura de promessa (+1 por mensagem enviada) e o detector de manipulação no inbound (+1 por mensagem recebida) — e eram decididas por variável de ambiente do WORKER: por processo, igual para todas as organizações, alcançável só por quem edita o .env da VPS e reinicia o contêiner. Num produto que a pessoa instala sozinha, isso é o mesmo que não existir. Tabela org_guardrail_layers com PK (organization_id, layer) e RLS por fn_user_org_ids(). layer SEM CHECK, na exceção de vocabulário ABERTO do CLAUDE.md: clone com valor desconhecido quebraria o update.sh, então o vocabulário vive no TypeScript e a coluna fica fora do invariante de vocabulário. Ausência de linha NÃO é "desligado" — sem linha vale o ambiente, e é isso que faz aplicar a migration não mudar o comportamento de quem não pediu nada; por isso a leitura devolve três estados (ligado, desligado, não escolhido) em vez de um booleano. Não reusa ai_purpose_bindings.is_enabled: lá false significa "ignore esta escolha de modelo", e mudar o significado quebraria 23 pontos para servir 2. |
| 20260810150000 | 0143_guardrail_layers_escrita_de_admin | Forward-fix da 0142: desligar uma camada de segurança era escrita de QUALQUER membro. A 0142 criou org_guardrail_layers com uma policy for all org-flat e deixou o gate de papel só na rota (admin no PUT). Rota não é fronteira: o ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon, authenticated deste baseline vale para toda tabela criada depois dele, então com a anon key (que vai ao browser) e o próprio JWT um viewer desarmava a defesa anti-jailbreak da organização pelo PostgREST, sem auditoria — medido num pg17 do zero: UPDATE 1 + INSERT 1. Passa à forma canônica do repo: org_guardrail_layers_select (leitura org-flat) + org_guardrail_layers_admin_write (fn_role_at_least(org,'admin'), cobrindo UPDATE e INSERT porque o PUT é upsert), mais revoke all ... from anon (precedente 0123). O raciocínio que produziu o furo estava escrito na 0142 — "nenhuma função nova, então não há grant a revogar" —, leitura errada de uma doutrina que fala de FUNÇÃO: tabela nova também nasce concedida. Guardado por tests/invariants/camadas-de-seguranca-rbac.test.ts (papel + privilégio de anon, com controle positivo em authenticated) e pela entrada de org_guardrail_layers em rls-isolation.test.ts — o teste de schema conecta como postgres (rolbypassrls), e por isso passava verde com a policy sabotada. |
| 20260810180000 | 0153_mensagem_editada_e_apagada | A mensagem que o cliente editou ou apagou no aplicativo. O evento chegava (quando assinado), o parser não o reconhecia, a rota respondia 200 e a linha ficava como estava — o atendente lia a versão velha, ou lia uma mensagem que do lado do cliente não existe mais. Sem erro em lugar nenhum, que é o que torna caro: combinar preço, prazo ou endereço a partir de um texto já corrigido gera divergência que ninguém rastreia depois. Duas colunas, não um estado: editada continua valendo (o texto novo é o que conta) e apagada deixou de valer (o texto não pode mais aparecer) — um estado text colapsaria as duas e obrigaria a tela a perguntar qual é antes de renderizar. Timestamp e não booleano porque a pergunta seguinte é "quando?", e "editada agora" e "editada ontem" pedem leituras diferentes numa negociação. O corpo é SOBRESCRITO e o original não é guardado: o CRM tem que mostrar o que o cliente vê agora, e histórico de edição é outra feature, com tela e retenção próprias — fazê-la pela metade acumularia dado pessoal num campo que a anonimização LGPD não conhece. A linha apagada não é removida: sumir com ela levaria o contexto das vizinhas e o histórico de quem atendeu. Aditiva e idempotente. |
| 20260810210000 | 0154_template_sabe_de_qual_conexao | O espelho de definições passa a saber de QUAL conexão cada uma é. meta_templates nasceu para um canal só, e a única marca de origem é waba_id — o id da conta na plataforma da Meta. Um segundo canal, com conta de outro formato, não tem onde entrar sem mentir sobre o que aquele campo significa. Medido: o endpoint resolve a sessão por metaSessionForOrg, então numa instalação com o canal intermediado e SEM o oficial ele devolve lista VAZIA — e o operador conclui que não tem nenhuma definição aprovada quando tem várias. A conexão, e não um provider text: a pergunta da tela é "quais posso usar NESTA conexão?", e dois números do mesmo provider têm definições diferentes; um campo de tipo responderia as duas com a mesma lista. É R da DIRC — a conexão já existe, aqui basta o ponteiro. NULL é estado legítimo: as linhas anteriores vieram do canal oficial e ninguém sabe de qual sessão são; inventar uma seria pior que admitir. on delete set null, não cascade: apagar uma conexão não pode levar junto o registro do que a plataforma aprovou — a definição continua existindo lá, e quem reconectar depois quer encontrá-la. Aditiva e idempotente. |
| 20260811200000 | 0151_arquivo_do_webhook_por_canal | O arquivo do corpo cru do webhook passa a aceitar os canais que entraram depois. webhook_events_log é o ÚNICO lugar onde o payload cru do provedor fica guardado, e a rota do canal por QR grava lá desde sempre. A rota genérica de canal — por onde entram os canais oficiais — nunca gravou nada: barrido feito antes desta migration, zero escritores naquele caminho. Não é lacuna abstrata: o comentário de lib/waha/ingest.ts mostra que a decisão sobre identidades opacas (@lid) só foi possível porque esse arquivo existia em produção — "76 de 76", contados no banco. O instrumento que respondeu aquela pergunta faltava justamente no canal NOVO, que é onde ainda há pergunta aberta (se mensagem de grupo chega; de qual host vem o anexo). Por que uma migration e não gravar 'generic': a coluna diz QUAL provedor mandou, e escrever "genérico" para um canal que se sabe qual é é a string que mente — o anti-pattern nº 1 da doutrina, no exato lugar que existe para investigar. Por que é seguro: é ALARGAMENTO — um CHECK que aceita mais valores não pode ser violado por linha que já passava pelo antigo, então não precisa de backfill nem deduplicação antes. O apêndice do baseline tem o BLOCO ÚNICO desta constraint (regra da issue #159): canal novo edita aquela lista em vez de acrescentar um segundo bloco. Idempotente. |
| 20260811210000 | 0152_o_gatilho_manda_o_texto | O emissor único passa a carregar o payload completo. A 0145 acompanhou a remoção da SEGUNDA emissão de message.received que o ingest por QR fazia (o gatilho já emite para todo canal, e os quatro consumidores rodavam duas vezes por mensagem — sentimento cobrado em dobro). Só que os dois payloads NÃO eram iguais: o do app tinha body_preview e channel_session_id, o do gatilho não. body_preview é lido pela condição "Texto da mensagem" das automações (event.body_preview, a ÚNICA condição de texto oferecida para o gatilho message.received) e interpolado em {{event.body_preview}} nas ações; sem ele, uma regra que o usuário configurou para reagir ao CONTEÚDO da mensagem deixa de casar em silêncio — não dispara e nada acusa por quê. Por que acrescentar ao gatilho e não devolver a emissão dupla: o certo é o emissor único carregar o payload completo, e assim o canal oficial — que nunca teve body_preview — passa a ter também: a condição de texto funciona nos dois canais pela primeira vez. Por que 280: é o mesmo corte da emissão removida; mudar o tamanho mudaria em silêncio o resultado de uma regra "contém" já configurada. left() e não substring(): aceita NULL e devolve NULL, que é o certo para mensagem sem corpo (mídia sem legenda) — a condição não casa, em vez de casar com string vazia. create or replace, idempotente. |
| 20260813120000 | 0156_quadro_do_onboarding | O quadro de clientes montado no onboarding, numa transação só. trg_seed_default_pipeline_for_org semeia o MESMO funil de e-commerce em toda organização criada — "Carrinho abandonado", "Em separação", "Enviado" — e num produto multi-nicho a clínica abre o quadro dela e lê isso. O gatilho fica: uma organização precisa nascer com ALGUM funil, senão o primeiro lead não tem onde entrar; o que faltava era o passo do wizard que troca esse quadro por um do ramo. E tem a metade invisível, medida neste banco em 2026-08-13: 312 etapas em 43 funis, 4 com agent_stage_hint — e as 4 são de organizações de teste. Toda instalação real nasce com coberturaDoFunil() devolvendo mudo: true: o assistente tem o funil no escopo e não sabe o que significa nenhuma coluna. Por isso a gravação insere o DESTINO junto com o nome. Por que uma função e não quatro chamadas do cliente: a troca é DELETE + INSERT, e os três índices únicos (_won, _hint, _slug) são imediatos, então as duas gerações não coexistem; como o cliente JS não tem transação, um DELETE que passa com um INSERT que falha deixaria o funil SEM COLUNA NENHUMA — pior que o quadro errado, e num passo onde ninguém sabe o que aconteceu. Duas recusas, e a segunda é a silenciosa: funil com negócio (crm_leads_stage_id_fkey é RESTRICT, o erro cru chegaria à tela) e etapa que é destino de fonte de webhook — webhook_sources.default_stage_id é ON DELETE CASCADE, então o DELETE não falharia, apagaria a fonte inteira em silêncio e as integrações parariam sem nada ficar vermelho. Devolve o motivo em jsonb, não raise: as duas são estados normais que a tela explica em português. EXECUTE só para service_role — medido: com revoke from public, anon apenas, authenticated continuava podendo, e aqui isso é o furo da 0149 de volta (a organização vem por argumento, a função roda como postgres). Idempotente. |
| 20260808180000 | 0119_contato_lookup_telefone | Carimbo da última TENTATIVA de descobrir o telefone por trás da identidade opaca. O WhatsApp passou a identificar quem escreve por um id opaco em vez do número, e uma instalação real acumula contatos sem telefone (medido: 52 de 54). O canal sabe traduzir, mas a tabela de tradução é povoada por ATIVIDADE — hoje não tem a resposta, semana que vem talvez sim. Uma varredura que escolhesse "sem telefone" sem carimbar reprocessaria SEMPRE os mesmos primeiros N e os do fim da fila nunca seriam perguntados; com o carimbo a ordem é "quem foi perguntado há mais tempo primeiro" e a fila gira. NULLABLE de propósito: NULL = nunca perguntado; com valor e phone_number ainda nulo = o canal não sabia na ocasião. Um not null default now() colapsaria os dois estados e faria contato novo nascer como "já tentado", que é justamente a distinção que a varredura precisa. Índice PARCIAL (where phone_number is null) porque só a minoria sem número interessa, e ela encolhe com o tempo. Aditiva e idempotente. |
| 20260808200000 | 0120_avisos_de_canal | Dois kinds novos na Central: channel_template_review e channel_number_alert. A plataforma revisa os modelos aprovados e mexe no número por conta própria — reprova um template, suspende a linha por falta de pagamento, exige KYC — e nada disso chegava ao operador: a descoberta acontecia no disparo que não saiu, com a campanha montada e o cliente esperando. Dois kinds, e não um por evento: suspenso, recusado, KYC pendente e liberado são desfechos diferentes da MESMA pergunta ("dá para enviar por este número?"), e um kind por evento encheria a Central de categorias que ninguém filtra separado; o evento exato vai no corpo do aviso. A constraint é RECRIADA no bloco único onde já vive (regra de baseline-constraint-reconstruida, #159): dois blocos fariam o update.sh de um clone com dados falhar no primeiro e deixar a tabela sem constraint entre o drop e o add. A lista foi copiada INTEIRA do baseline — a primeira versão omitia promise_unfulfilled e other, e teria apagado dois kinds em uso. Aditiva e idempotente: a lista só cresce, nenhuma linha existente passa a violar. |
| 20260813090000 | 0155_marca_da_instalacao_no_banco | A marca da instalação sai do .env e vai para o banco. Nome, logo e cor viviam só em APP_NAME/APP_LOGO_URL/APP_ACCENT_HEX: trocar qualquer um exigia SSH na VPS, editar o .env e reiniciar a stack — para quem compra hospedagem e instala sozinho, isso é o mesmo que não ser configurável. Tabela singleton platform_branding (id smallint primary key default 1 + CHECK id = 1), semeada do .env na PRIMEIRA leitura com seeded_from_env = true. O .env continua sendo escrito, e isso não é redundância: o agent.sh do kit, em falha de update, reverte só a IMAGEM (APP_IMAGE) — não o schema, não o git checkout — e o update.sh aplica o baseline ANTES de puxar a imagem. O rollback põe código antigo sobre banco novo por construção, e código antigo não conhece esta tabela: se a marca só existisse aqui, ela sumiria no meio de um rollback. Com o .env intacto ela degrada para o valor da instalação. seeded_from_env não é enfeite de proveniência: a escrita humana o zera, e a semeadura só corre sobre linha ausente ou linha seeded_from_env = true e vazia — é o que impede o .env de desfazer no render seguinte a escolha de quem apagou os campos de propósito. fallback_at/fallback_reason são ESTADO, não configuração (invariante 6 do Sistema Vivo): quando a serialização recusa a cor, o produto degrada para a dele, e sem essas colunas o degrade é indistinguível de "a feature nunca foi instalada" — o operador veria a cor do produto e concluiria que o campo não funciona. Gravadas e limpas por lib/branding/instalacao.ts (alarme que não apaga sozinho ensina a ignorar alarme), sempre em FORMA e nunca em IDENTIDADE: nenhum hex de marca entra no diagnóstico. RLS ligada com ZERO policies + revoke all ... from anon, authenticated. As duas coisas, e nenhuma substitui a outra: RLS sem policy é a forma explícita de dizer que o PostgREST não serve a tabela; o revoke é o análogo, para TABELA, da regra de security definer do item 9 do CLAUDE.md — o ALTER DEFAULT PRIVILEGES ... GRANT ALL ON TABLES TO anon/authenticated do baseline vale para toda tabela criada depois dele, então tabela nova nasce concedida. Foi assim que nasceu a vulnerabilidade que a 0143 consertou (org_guardrail_layers: um viewer desligava a camada anti-jailbreak pelo PostgREST). Guardado por tests/invariants/marca-da-instalacao.test.ts, que mede PRIVILÉGIO e comportamento (set role anon → 42501), com controle positivo em service_role. Sem event_log: nenhum dos 12 handlers de lib/event-log/register-handlers.ts cobriria um tipo platform_branding.*, e o drain deixa evento sem handler intocado — a linha nasceria pending para sempre em todo clone (anti-pattern nº 3). O registro é audit() com a ação nova platform_branding.updated. accent_hex tem CHECK de REGEX (^#[0-9a-f]{6}$), não de conjunto, e por isso NÃO entra na lista PARES de tests/invariants/vocabulario-banco-x-typescript.test.ts, cujo extrator só reconhece = ANY (ARRAY[...]). Aditiva e idempotente. |
| 20260814090000 | 0157_marca_por_organizacao | A marca de cada organização se grava em UMA instrução, por função. O revendedor hospeda várias empresas e cada uma quer o próprio nome e a própria cor dentro do produto; o lugar do dado é organizations.settings.branding. O que essa migration resolve não é onde guardar, é COMO escrever: organizations.settings já tinha três donos com gates diferentes — a aba Organização (admin, updateTenant.ts), o PATCH de atendimento (manager) e a régua de atrito (manager) — e os três leem o jsonb INTEIRO, espalham em memória e regravam o objeto inteiro em round-trips separados, sem transação, sem lock e sem versão. A perda foi medida, não deduzida: o manager restringe visibility_mode de all para own, o admin salva a aba de identidade com o snapshot velho, e o valor volta para all — sem erro em lugar nenhum. E visibility_mode é lido DIRETO pela RLS, dentro de fn_can_view_conversation e fn_can_view_lead: um write de COR reverteria, em silêncio, uma decisão de exposição de dado de cliente. Um quarto escritor com o mesmo padrão era inaceitável, então a marca por organização passa por fn_definir_marca_da_organizacao(p_org, p_actor, p_marca), que faz jsonb_set numa instrução só — o objeto nunca sai do banco, e as chaves irmãs (llm, routing, visibility_mode, atrito, lost_reasons_extra, plan) não têm como se perder. Devolve integer, não void: a única policy de escrita de organizations é orgs_write_platform_admin (FOR ALL, USING fn_is_platform_admin()), então pelo client de sessão o UPDATE de um admin de TENANT casa 0 linhas e o PostgREST responde 204 — error chega null no supabase-js, a tela diz "salvo" e nada foi gravado (issue #144). O row_count é o que permite ao chamador distinguir "gravou" de "não gravou", e a action reprova data !== 1. A autorização é REPETIDA dentro da função (admin da própria org, ou platform admin, ambos com revoked_at is null): o gate da Server Action usa o snapshot de membership de loadAuthUser(), não o banco, e Server Action não passa por requireRole — repetir a checagem aqui é o que faz a regra valer para qualquer chamador futuro e o que impede escalação caso o EXECUTE escape um dia. Papel insuficiente levanta 42501; forma inválida levanta 22023; falhar alto em vez de devolver 0 existe porque 0 já significa "a organização não existe", e colapsar os dois deixaria o chamador sem saber se o problema é papel ou id. accent_hex valida com a MESMA regex do CHECK platform_branding_accent_hex (^#[0-9a-f]{6}$, a forma que normalizarHex emite): dentro de jsonb não cabe CHECK de coluna, então a regra é da função — e por não ser CHECK de CONJUNTO ela não entra na lista PARES de tests/invariants/vocabulario-banco-x-typescript.test.ts. Os dois revokes do item 9 do CLAUDE.md (from public, anon, authenticated) + grant to service_role: são origens distintas de EXECUTE e tratar só uma deixaria a função exposta com o gate verde — e como esta é VOLÁTIL, authenticated sairia com escrita cross-tenant na mão. Sem event_log: nenhum dos 12 handlers de register-handlers.ts cobre org.*, e o drain deixa evento sem handler intocado — a linha nasceria pending para sempre em todo clone (anti-pattern nº 3). O registro é audit() com a ação nova org.branding_updated, carregando FORMA e nunca o hex. Guardada por tests/invariants/marca-da-organizacao.test.ts (7 casos, incluindo a catraca do 0-linhas com controle positivo e a sonda de privilégio nos três papéis). create or replace, sem constraint nova sobre dado existente: idempotente e sem backfill. |
| 20260814140000 | 0158_logo_no_storage | O logo sai da caixa de texto e vira ARQUIVO. Até aqui "logo" era platform_branding.logo_url — uma URL que alguém colava; quem instala o sistema para clientes não tem onde hospedar um PNG, então o campo ficava vazio e a marca do revendedor não aparecia. Traz o bucket brand-logos, platform_branding.logo_path, a função fn_definir_logo_da_organizacao e um forward-fix da 0157. O bucket é PÚBLICO e é o primeiro do repositório (os quatro anteriores nascem public=false) — razão medida: o logo é renderizado num <img> da tela de LOGIN, servida a quem não tem sessão, e URL assinada VENCE (a marca sumiria da fachada sozinha, e "o logo sumiu" não apontaria para a causa). A exceção é contida e o invariante tests/invariants/marca-logo.test.ts a mede: bucket exclusivo de logo, ZERO policy em storage.objects (público abre LEITURA pelo endpoint /object/public/..., não INSERT nem DELETE), caminho não-enumerável <prefixo>/<uuid v4>.<png|jpg>, e allowed_mime_types como backstop apenas — o Storage compara com o header que QUEM SOBE escolhe, então quem decide o tipo é o farejador de bytes de lib/branding/logo-arquivo.ts (SVG é BANIDO: não há CSP neste repo e SVG navegado direto executa script). Teto de 512 KB porque images.unoptimized faz o arquivo ir inteiro ao navegador em toda página e a cota do Supabase é do CLIENTE (1 GB, compartilhado com whatsapp-media, que não tem poda). Grava-se o CAMINHO, nunca a URL (DIRC-C): a URL é função determinística de caminho + host, e gravá-la amarraria a marca ao host do projeto de hoje. fn_definir_logo_da_organizacao assevera o prefixo contra p_org DENTRO do banco — sem isso um admin de tenant grava o platform/... que qualquer pessoa lê no HTML do login como logo dele e, na troca seguinte, o delete-on-replace (rodando como service_role) apaga o logo da instalação inteira. O forward-fix da 0157 é a metade não-óbvia: aquela função faz jsonb_set(settings,'{branding}',p_marca) e substituiria o objeto inteiro, então salvar o nome apagaria o logo — em silêncio, com a tela dizendo "salvo". Ela passa a preservar logo_path via p_marca || jsonb_strip_nulls(...). No baseline.sql o apêndice entra em DOIS blocos, e não é estilo: tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts proíbe create function depois da varredura de anon, mas platform_branding é criada num bloco que vem DEPOIS dela — bloco único quebraria o install.sh (ON_ERROR_STOP=1, tabela inexistente) ou deixaria as duas funções expostas a anon em quem ATUALIZA. Funções antes da varredura, bucket e coluna no fim. CHECK de logo_path é de REGEX, não de conjunto ⇒ fora de PARES em tests/invariants/vocabulario-banco-x-typescript.test.ts. Backfill ANTES da constraint (o update.sh roda sem ON_ERROR_STOP e engoliria o 23514, deixando a coluna sem validação em silêncio). Aditiva e idempotente. Dívida declarada: não há cron de órfãos — um logo tem ≤512 KB e muda raramente, encher 1 GB exige ~2000 uploads que falharam entre o upload e a gravação. |
| 20260814210000 | 0159_o_teto_que_vincula | A tela prometia "a IA pausa ao chegar no limite" e nenhuma organização estava protegida. A tela editava ai_budgets.monthly_limit_cents; o enforcement lia organizations.settings.llm.monthly_budget_cents, um escalar num jsonb livre sem nenhum escritor de produto (o único no repositório é scripts/smoke-llm.ts) e lido por um Zod com .catch() que transforma forma errada em null — e null é ilimitado. Dois campos, duas fontes, nenhuma ligação. Traz duas colunas em ai_budgets (enforcement_mode, enforcement_effective_at), o resgate B→A, o saneamento de is_throttled, o kind budget_warning e fn_gasto_de_ia_do_mes como definição ÚNICA de gasto. Três coisas que a próxima triagem vai querer sem reler o SQL: (a) esta migration NÃO ARMA NINGUÉM — enforcement_mode nasce 'off' pelo DEFAULT do ALTER (sem UPDATE, sem heurística, sem backfill) e enforcement_effective_at nasce null, e null <= now() nunca é verdadeiro; o único bloco que escreve 'bloquear' é o resgate B→A, rotulado e delimitado, que preserva o bloqueio de quem HOJE já é bloqueado pelo jsonb — a afirmação conferível é nenhuma organização ganha uma capacidade de bloqueio que já não tivesse, vigiada por tests/unit/migracao-nao-arma-ninguem.test.ts com controle negativo. (b) NENHUMA linha reescreve monthly_limit_cents fora do resgate — é isso que torna o apêndice idempotente sem guarda de catálogo, e quem "simplificar" reintroduz perda da configuração a cada update.sh. Pela mesma razão não há alter column monthly_limit_cents: um desenho concorrente morreu ali (medido em pg17) pondo set … = null num do $$ com o drop not null DEPOIS do bloco — em banco novo o UPDATE casava 0 linhas e o CI ficava VERDE, em clone instalado estourava não-nulo, o do $$ inteiro caía, o update.sh (sem ON_ERROR_STOP) engolia, exit 0, e a coluna nova ficava INEXISTENTE, derrubando toda chamada de IA do clone com 42703 no release seguinte. (c) o resgate tem TRÊS cortes, e cada um evita um erro medido: jsonb_typeof = 'number' (o ::numeric de uma string levantaria 22P02 no update.sh de um clone, e forma errada JÁ é ilimitado hoje — não resgatar é preservar); >= 100 (o 0 significava "sem limite" na tela e "bloqueia TUDO" no enforcement, então trazê-lo desarmado é a única mudança de comportamento, e é na direção que AFROUXA); e <= 2147483647 porque a coluna é integer — medido em pg17: com 1e20 no jsonb o UPDATE levanta 22003, e como ele é UM statement o resgate INTEIRO se perde em silêncio (sabotagem medida: 2 organizações resgatadas viram 0, com o update.sh saindo 0). fn_gasto_de_ia_do_mes é security invoker, não definer: recebe a organização por argumento e não valida membership, então definer alcançável por authenticated seria leitura de gasto cross-tenant. E por ser invoker ela depende inteiramente dos próprios revokes — o bloco VARREDURA anon do baseline percorre só p.prosecdef e NÃO cura função invoker; medido em pg17 removendo a linha, proacl passa a ter anon=X e authenticated=X nos dois caminhos. Dos dois CHECKs novos, só ai_budgets_bloquear_precisa_de_teto é cross-coluna / de domínio e fica fora de PARES em tests/invariants/vocabulario-banco-x-typescript.test.ts (mesma classificação dos CHECKs de regex da 0155/0157/0158). ai_budgets_enforcement_mode_check NÃO é — check (enforcement_mode in ('off','avisar','bloquear')) é vocabulário puro de conjunto, e o par em TypeScript existe e roda no caminho quente (ModoDeOrcamento, lib/agent-engine/edge/llm/orcamento.ts). A classificação errada atravessou desenho e implementação e mantinha a coluna fora do único gate que pega essa classe de defeito; corrigida: o par está em PARES. No baseline.sql o apêndice entra em TRÊS pontos e a ordem é load-bearing: a função ANTES do bloco da varredura de anon (que proíbe create function depois dele, e o regex não distingue definer de invoker), o kind DENTRO da lista única de agent_inbox_items_kind_check (regra da issue #159), e o resto no FIM. Exercitada num pg17 real nos dois caminhos: install fresco com ON_ERROR_STOP=1 (exit 0) e clone com dados re-aplicando sem a flag (301 erros, os MESMOS 301 do controle sem esta migration, nenhum vindo destes blocos). Idempotente: a terceira aplicação deixa o estado idêntico ao da segunda. |
| 20260815120000 | 0160_ai_budgets_so_escreve_pela_rota | A escada, a carência e a auditoria do teto eram contornáveis pela REST do Supabase. A 0159 pôs em ai_budgets os campos que decidem se (e quando) a IA para; toda a regra que os protege mora na rota PATCH /api/v1/ai/budget (service role). Mas o corpo do dump traz GRANT ALL ON TABLE public.ai_budgets TO anon e TO authenticated, a policy de escrita é for all com fn_role_at_least(...,'admin'), e a 0159 termina com notify pgrst, 'reload schema' — logo as colunas novas passaram a ser SERVIDAS pelo PostgREST para a chave anon, que vai ao browser: um PATCH direto na REST armava a parada sem escada, sem carência, sem piso e sem linha em api_audit_log. O comentário de coluna da 0159 ("escrito só por PATCH /api/v1/ai/budget") era verdade sobre o CÓDIGO e falso sobre o SCHEMA. revoke insert, update, delete ... from authenticated, anon — SELECT fica (a policy de SELECT da 0150 já escopa a leitura, e tirá-la quebraria clone que montou painel próprio na anon key). Medido antes de revogar: TODO escritor de ai_budgets no repositório usa service role (a rota, lib/ai/budget/check.ts, os painéis de admin, os workers e scripts/qa-wave-11.ts), então o revoke não quebra caminho de produto nenhum. revoke é idempotente por natureza, então o apêndice do baseline pode ser re-aplicado pelo update.sh à vontade. NÃO MEDIDO contra um PostgREST vivo (Docker indisponível na máquina em que foi escrita): o achado e o conserto vêm de leitura de grants e policy no baseline.sql. |
| 20260819140000 | 0161_outbound_zera_unread | Badge da inbox inflava e nunca caía após resposta. fn_mark_conversation_message só incrementava unread em inbound; envio pelo CRM (sendMessageHandler) nem chamava a RPC — contador acumulava mensagens já respondidas. Outbound passa a zerar unread_count_for_assignee; envio CRM também zera na atualização da conversa; rota POST .../mark-read + hook useMarkAsRead (1,5s em foco) cobre leitura sem resposta. |
| 20260819150000 | 0162_contato_ultima_atividade_mensagem | "Última atividade" em /app/contacts ficava parada após mensagens. A coluna contacts.last_activity_at só era tocada pelo trigger de crm_lead_activities; ingestão de WhatsApp/Meta/Zernio atualiza conversations via fn_mark_conversation_message sem carimbar o contato. A RPC passa a fazer greatest em contacts.last_activity_at; backfill a partir de conversations.last_message_at; envio CRM (sendMessageHandler) também carimba o contato (não usa a RPC). O relógio do lead segue na lista positiva da 0079. |
| 20260820010000 | 0163_o_arquivo_do_webhook_pode_perder_o_corpo | O arquivo do payload cru ganha retenção — e a coluna que já existia para isso ganha dono. webhook_events_log é o único lugar onde o corpo do webhook fica guardado, e nunca foi podado. Medido numa instalação real em 20/08/2026: banco em 545 MB, dos quais 468 MB (86%) eram esta tabela — contra 3,2 MB de messages. Nenhuma linha com mais de 30 dias: as 56.291 eram de 20 dias, ~23 MB/dia, ~700 MB/mês, sem teto. O plano gratuito do Supabase acaba em 500 MB, que é onde a maioria dos clones vive. Esvaziar, não apagar: as três colunas pesadas (raw_body, payload_parsed, headers) são ~97% do peso e a linha sem elas custa ~200 B — apagar responderia ao espaço e mataria o instrumento, porque "quantos eventos de que tipo chegaram, quando, e a assinatura conferia?" é justamente a pergunta de depois do incidente (foi ela que decidiu o caso das identidades opacas contando "76 de 76"). O índice forense inteiro sobrevive por ~11 MB. Por que raw_body passa a aceitar NULL: not null está certo para quem ESCREVE, mas sem NULL não há como dizer "o corpo existiu e foi descartado" — string vazia se confunde com corpo vazio de verdade, que é caso real (ping). Medido antes de afrouxar: nenhum leitor consulta raw_body no repositório (os 7 aciertos são INSERT, e a única tela que lê o arquivo pede payload_parsed). archived_at já existia e não tinha dono — 0 linhas com valor em 56.350, promessa de esqueleto (anti-pattern nº 3); ganha dono aqui em vez de nascer coluna nova com o mesmo significado. Índice PARCIAL where archived_at is null porque ele encolhe conforme a poda avança. Aditiva e idempotente: afrouxar not null não pode ser violado por linha que já passava. |
| 20260820030000 | 0164_atribuicao_de_anuncio | De qual anúncio um contato do WhatsApp veio. contacts.source/source_metadata já existiam (nasceram genéricos, 'whatsapp'); esta migration não cria coluna, só fn_estampar_atribuicao_de_anuncio(p_contact, p_platform, p_metadata) — chamada pelo ingest de canal (WAHA e o canal oficial) quando a primeira mensagem de um contato novo traz dados de um clique em anúncio "Clique para o WhatsApp" da Meta. Função, não UPDATE direto do aplicativo: o merge de source_metadata (||, preserva waha_lid/waha_chat_id/notify_name que fn_upsert_wa_contact já gravou) e a guarda de PRIMEIRO TOQUE (source_metadata->>'ad_platform' is null) precisam ser UMA operação atômica — duas mensagens quase simultâneas do mesmo contato novo não podem correr a corrida de ler+mesclar+escrever em JS. Primeiro toque, nunca sobrescreve: clicar em outro anúncio meses depois, numa conversa já aberta, não reescreve de onde a pessoa veio ORIGINALMENTE — o UPDATE casa zero linhas, silenciosamente, quando já há ad_platform gravado. Os dois revokes do item 9 do CLAUDE.md (from public, anon, authenticated + grant to service_role): só o backend chama isto (admin client no ingest), nenhum papel de sessão precisa. Idempotente (create or replace) e sem backfill: não altera dado existente. |
| 20260820120000 | 0166_indice_do_claim_da_fila | O cap global do claim varria job_queue inteira a cada rodada. claimJobs (lib/agent-engine/queue/queue.ts) abre toda rodada — dentro do advisory lock que serializa os claimers — com select count(*) from job_queue where status = 'running', e nenhum dos quatro índices da tabela servia esse predicado. O parcial das lanes (uniq_job_queue_one_running_per_contact) chega perto e não vale: o predicado dele é mais ESTREITO (exclui contact_id is null, que é todo watchdog/flywheel), então o planejador não pode responder por ele. Medido em pgvector/pgvector:pg17 com este baseline, 50.000 linhas done + 4 running: Seq Scan / 715 buffers → Index Only Scan / 3 buffers, índice de 16 kB. O custo não depende de linha viva, depende de bloat: apagando as 50.004 dentro de uma transação e perguntando de novo, o Seq Scan ainda lê os mesmos 715 buffers — ele visita PÁGINA, não tupla —, e nada no produto poda job_queue. O /healthz do worker NÃO é consertado por isto, e foi medido, não suposto: workers/agent-worker/main.ts e lib/agent-engine/obs/metrics.ts fazem select status, count(*) from job_queue group by status, que precisa de TODOS os status; com o índice novo o plano continua HashAggregate sobre Seq Scan, 715 buffers, idêntico ao de antes — e um índice CHEIO em (status) também não move (medido: o planejador segue escolhendo Seq Scan, e o índice custaria 360 kB). Fica fora do escopo: aquilo roda em probe do Docker a cada 30s, não no caminho quente do claim. O bloco do $$ não é cerimônia: create index if not exists casa por NOME e não por definição, e as duas variantes foram medidas em pg17 — homônimo na própria job_queue com outra definição vira NOTICE: ... already exists, skipping (NOTICE nem chega ao filtro ERROR\|FATAL do update.sh: no-op perfeitamente silencioso), e homônimo em OUTRA tabela (nome de índice é único por SCHEMA) dá o mesmo no-op e faz o comment on index acertar o índice errado, cegando o delator. O bloco derruba e recria o homônimo NOSSO em job_queue; o de outro objeto ele não apaga — raise exception com a razão escrita, que é o comportamento certo num script que roda sem ON_ERROR_STOP (o erro aparece ao operador e o resto do baseline segue). O comment on index é DELATOR: índice ausente levanta relation "idx_job_queue_running" does not exist, texto conferido contra a lista benigna real do update.sh (already exists\|multiple primary keys\|multiple default values\|is already a member\|already a partition) — não casa com nenhum termo, logo chega ao cliente; um already exists seria engolido. Guardado por tests/invariants/queue-cap-global-do-claim.test.ts, que extrai o select do FONTE de produção em vez de copiá-lo: índice presente e predicado mudado (::text a mais, lower(status), status in (...)) devolveria Seq Scan com o símbolo intacto. Aditiva e idempotente: sem constraint, sem backfill, sem dado tocado. |
| 20260827180000 | 0196_followup_pointer_surface | Um motor, duas listas. followup_flow_pointers.surface (followup | crm_automation, default 'followup') recorta a lista de follow-up da IA da seção CRM Automação sem clonar tabela, cron nem canvas. CHECK de conjunto — par em tests/invariants/vocabulario-banco-x-typescript.test.ts. Aditiva e idempotente: ADD COLUMN IF NOT EXISTS + drop/add da constraint. Sem backfill: o default cobre linha já existente. |
| 20260827181000 | 0197_push_subscriptions | Web Push: inscrição por navegador. Tabela push_subscriptions (endpoint + p256dh/auth), RLS da própria linha (organization_id in fn_user_org_ids() e user_id = auth.uid()), unique em endpoint. Sem ela o servidor não tem destino para o protocolo Web Push. Aditiva. revoke em anon/public; grant CRUD a authenticated. |
| 20260827182000 | 0198_nono_digito_canonico | Celular BR grava e mostra COM o nono dígito. +553284793302 e +5532984793302 são a mesma pessoa; o CRM unifica na forma com o 9. fn_upsert_wa_contact busca as duas grafias, promove 12→13 quando o canônico está livre, e o backfill funde pares duplicados + reescreve quem só tinha 12 dígitos. Fixo (local 2–5) e estrangeiro não mudam. O envio ao WhatsApp/WAHA continua tentando as duas grafias via check-exists. Idempotente (create or replace + updates que na segunda passada casam zero linhas). |
| 20260820160000 | 0165_identificador_de_canal_unico_entre_ativos | Os dois identificadores de canal que chegaram depois do snapshot não tinham trava nenhuma, e é por eles que o código resolve credencial de envio e o DONO de uma mensagem que acabou de entrar. waha_session_name e webhook_path_token são UNIQUE desde o snapshot; meta_phone_number_id (0087) e zernio_account_id (0131) nasceram sem. Traz dois índices únicos PARCIAIS (where archived_at is null), pelo precedente da 0107 — canal arquivado é canal excluído e a linha só sobrevive como âncora das FKs RESTRICT, então trava total impediria reconectar o mesmo número depois de excluí-lo. O que a ausência produzia (issue #236): três consultas de lib/channels/ resolviam a sessão só pelo identificador, em client de service role (bypassa RLS). Com duas linhas casando, maybeSingle() não devolve "a primeira" — medido contra @supabase/postgrest-js 2.112.1, devolve data: null + error PGRST116 (HTTP 406). Os três descartavam o error, então: os dois resolvedores de credencial caíam no fallback do .env (a mensagem saía pela conta de OUTRA instalação) e meta/ingest.ts devolvia no_session com a rota respondendo 200 — a mensagem recebida era descartada para as duas organizações. Classificação: alta, não crítica — a colisão é atingível por configuração LEGÍTIMA (agência, migração de conta entre organizações), não por reivindicação hostil: validatePartnerCredentials (lib/channels/connect.ts) exige que o identificador esteja na lista de contas que a chave informada alcança. A deduplicação vem ANTES da trava e não é destrutiva: o update.sh do clone roda sem ON_ERROR_STOP e engoliria o 23505, deixando o clone sem trava e sem aviso. Apagar está fora (sessão de cliente, histórico por FK RESTRICT) e arquivar faria o canal sumir da tela sem ninguém pedir — a perdedora é RENOMEADA para <original>-conflito-<id da sessão>: continua visível, e como o identificador não existe no provider a varredura de saúde (app/api/v1/cron/channel-health) grava FAILED/STOPPED na passada seguinte e abre aviso na Central (os três estados estão em STATUS_QUE_AVISAM). Fica com o identificador a sessão ativa mais recente (criá-la exigiu provar posse da conta na tela de conexão ⇒ é a intenção mais recente); errar o palpite não destrói nada e a linha perdedora ela mesma avisa. Idempotente por construção: o sufixo carrega o id (único), então a segunda passada casa zero linhas e não há como sufixar duas vezes. Nomes de índice novos (conferidos contra baseline.sql e migrations/), então o if not exists — que casa por NOME — não vira no-op em cima de um homônimo com outra definição. O código foi corrigido nas três camadas junto: filtro de organization_id nos três sítios (com o organizationId atravessando o seam de canal como campo obrigatório, para o typecheck cobrar), error que deixa de ser descartado, e o invariante tests/unit/canal-consulta-por-organizacao.test.ts, que varre lib/channels/ derivando as colunas de CHANNEL_SESSION_REF_COLUMNS — o quarto canal entra na varredura sozinho. |
| 20260820170000 | 0167_poda_da_fila_e_expurgo_do_audit | Nada no produto apagava job terminal, e a retenção de 5 anos do audit existia só no COMMENT. grep -rn "from job_queue" lib workers app supabase scripts | grep -i delete devolvia zero linhas: job_queue crescia desde a instalação e nunca encolhia; api_audit_log prometia 5 anos + "hot 90 dias / cold S3" em seis documentos, sem uma linha de código que executasse qualquer das duas metades. São as candidatas naturais a estourar os 500 MB do plano free antes de qualquer tabela de negócio — e o bloat também custa CPU (715 buffers varridos no count(*) do claim com ZERO linhas vivas, medido na #260). Traz duas security definer (fn_podar_fila_de_jobs, fn_expurgar_auditoria_vencida), três índices e o cron data-retention (diário). DELETE por idade e não particionamento: particionar job_queue exigiria mexer no claim FOR UPDATE SKIP LOCKED e nos dois índices ÚNICOS parciais que garantem um turno por lead — trocar essa garantia por disco é péssimo negócio. O QUE TEM DONO NÃO SAI, e são três cortes: pending/running nunca saem (o primeiro ainda vai sair, o segundo está com um worker e o reaper o devolve); terminais são só done/failed/dead (conferidos em lib/agent-engine/queue/queue.ts); e dead com aviso ABERTO na Central tem dono — um humano que não olhou — com o not exists antes do limit, porque filtrar depois faria um lote de protegidos devolver 0, o laço do cron pararia achando que acabou e a poda morreria de fome com backlog na frente. Cascata declarada: o DELETE leva junto send_ledger e before_send_traces (FK on delete cascade, as duas também sem poda) e apenas anula o ponteiro em llm_calls/lead_checkpoints/lead_state_transitions; os dois consumidores de send_ledger sem janela (countPriorAcceptedSends → disclosure de IA, e o gate LGPD de 1º toque de prospecção, before-send.ts:261) falham fechado — disclosure a mais e veto a mais, nunca a menos —, daí piso de 7 dias e default de 90. A definer do audit não é porta de adulteração, e cada razão é conferível: (a) não tem seletor de linha — nenhum parâmetro de org, ator, ação ou id, e o único predicado é created_at < now() - N dias, então ela só sabe apagar pela ponta mais velha; (b) o piso de 90 dias mora no corpo, não em quem chama, então nem com a service key se remove rastro recente; (c) revogada das duas origens de EXECUTE e concedida só a service_role; (d) não amplia o raio de quem já tem a chave (service_role já tem TRUNCATE na mesma tabela); (e) registra a própria erosão — o cron grava retention.sweep_run com a contagem, e essa linha é nova demais para a chamada seguinte alcançar. api_audit_log não tinha GRANT de DELETE para ninguém (nem para service_role), e é por isso que o expurgo não podia sair pelo admin client. Hot/cold em S3 não foi entregue e a doutrina do CLAUDE.md foi corrigida em vez de fingir: o self-host não tem para onde arquivar (o Storage do cliente é a MESMA cota de 1 GB, já dividida com whatsapp-media). Índices com nome próprio da poda (idx_audit_expurgo_created_at) porque create index if not exists casa por NOME e um nome genérico viraria no-op silencioso num clone. Aditiva, sem constraint nova (nada a deduplicar antes), idempotente por create or replace + if not exists + revoke. NÃO MEDIDO: o comportamento sob milhões de linhas reais — os lotes foram exercitados no Postgres efêmero do test:db, não numa VPS com histórico de anos. |
| 20260821090000 | 0168_a_mensagem_que_responde_outra | A citação: qual mensagem esta responde. O canal intermediado aceita replyTo no envio (recebendo o wamid da citada) e o WhatsApp mostra a resposta pendurada na original — que é como as pessoas conversam ali. Sem guardar QUEM foi citado, o CRM manda a citação para o cliente e não consegue mostrá-la de volta na própria tela: o atendente vê frases soltas onde o cliente vê um fio. FK e não o wamid solto: a pergunta da tela é "qual mensagem NOSSA foi citada?", e a resposta é uma linha desta tabela; guardar o texto do provider faria toda renderização buscar por coluna de texto e deixaria de funcionar para a citação de uma mensagem que ainda não tem external_id (a nossa, enquanto está queued). É o R da DIRC — a linha existe, aqui basta o ponteiro, e o id que o provider precisa sai dela no momento do envio. on delete set null, nunca cascade: apagar a citada não pode levar junto a resposta, que é conteúdo próprio de alguém; perder o fio é aceitável, perder a resposta seria apagar histórico por causa de um ponteiro. Índice PARCIAL porque a esmagadora maioria não cita ninguém. Aditiva e idempotente. |
| 20260824120000 | 0173_quem_manda_na_conversa | "Assumir" não calava o automático — dois atores atendiam o mesmo cliente. Medido no HEAD 927dfa51: grep -rn "assignee_kind\|assigned_to_user_id" lib/agent-engine/ devolve rc=1 (zero acertos) e fn_conversation_assign nunca tocou bot_silenced_until. O motor moderno é dono da resposta sempre que a org tem agente publicado, e ele nunca soube que alguém assumiu: o automático só calava por 5 minutos deslizantes quando o atendente ENVIAVA (extendBotSilence, app/api/v1/messages/_handler.ts). O guard existe desde a decisão G3-02 e só o worker LEGADO o implementa (skip("assigned_to_human")) — o motor regrediu sem ninguém notar, e é isso que faz qualquer selo de "você está no comando" ser mentira. O conserto entra na função de atribuição, não no motor, porque bot_silenced_until é o gate que o motor JÁ lê (isLeadInHandoff): nenhuma linha do motor muda. A alternativa foi medida e REPROVADA: ensinar o motor a ler assignee_kind parece a correção óbvia e produz mudez permanente — Fechar não solta o dono, de propósito ("quem atendeu é histórico"), então o fim NORMAL de um atendimento (Assumir→Fechar) deixa assignee_kind='user' pendurado; e como aquele gate é por CONTATO, ele calaria também conversa NOVA de OUTRO número do mesmo cliente (reproduzido em pgvector/pgvector:pg17 com este baseline: gate atual f, gate com assignee_kind t, sobre uma conversa closed). Três braços, e o do rodízio é o que impede a regressão silenciosa: p_reason='routing' não mexe no silêncio — distribuir não é assumir, e trg_conversation_routing_requested dispara em TODA conversa nova com o worker rodando 1×/min, então sem a ressalva uma org em round_robin ficaria com o automático calado na PRIMEIRA mensagem da vida de cada cliente (medido), numa tela de configuração que não menciona IA; destino humano (claim/transfer) → 'infinity'; destino nulo (release) → null. Assinatura IDÊNTICA de 6 args de propósito: parâmetro novo criaria OVERLOAD (o create or replace não substitui assinatura diferente) e as cinco chamadas por nome passariam a falhar com is not unique — medido. A limpeza do silêncio ao FECHAR mora na rota (close/route.ts), porque fechar não passa por esta função e sem ela o silêncio vazaria para o próximo episódio: a ingestão reusa a MESMA linha de conversa (on conflict do update). De brinde, um furo de RLS que a mesma medição achou: cae_select era membership de org PURA enquanto conversations_select passa por fn_can_view_conversation — org em visibility_mode='own', agent que não é dono lia 0 linhas em conversations e 1 em conversation_assignment_events da mesma conversa, alcançável pelo PostgREST com a anon key + o JWT do usuário, sem depender de rota nossa. A policy passa a HERDAR o escopo no molde do messages_select (exists sobre conversations, que já aplica a RLS dela) em vez de reescrever a regra — duas cópias divergem na primeira mudança de uma delas. Idempotente (create or replace + drop policy if exists), sem constraint nova, sem dado a corrigir. |
| 20260825120000 | 0174_historico_de_leads_captados | Quem publica uma landing page não tinha como responder "chegou alguém, com que dados, de onde?". A única coisa que existia era o ARQUIVO FORENSE (webhook_events_log), e ele é DESCARTÁVEL por desenho: o cron webhook-log-retention (a cada 5 min, migration 0163) zera raw_body/payload_parsed/headers em D+7 e apaga a linha em D+90. Foi a decisão certa — numa instalação real ele era 468 MB de um banco de 545 MB (86%), contra 3,2 MB de messages — mas transforma qualquer histórico construído sobre ele numa tela que MENTE a partir do sétimo dia: os campos viram null e nada na UI distingue "o formulário veio vazio" de "o corpo foi descartado ontem". Um arquivo de depuração e um histórico de negócio têm ciclos de vida OPOSTOS. Traz webhook_lead_captures com o que o arquivo não guardava: o IP em coluna tipada (inet — no arquivo ele só existia solto dentro do jsonb headers, que é uma das três colunas podadas em D+7); o DESFECHO (criado/duplicado/recusado + reject_reason) — o arquivo registra "chegou um POST" e não sabe dizer se virou lead, se caiu na deduplicação por external_id, ou se foi recusado por não ter campo mapeável, que é justamente o caso em que a pessoa hoje NÃO VÊ NADA (400 para o site dela, zero rastro na tela); e o nome da fonte NO MOMENTO da captação (cópia deliberada — a FK é on delete set null e o histórico responde de onde o contato VEIO, não de onde viria hoje; é o caso em que duplicar é a resposta certa da DIRC, porque o valor é um fato datado). RLS exige manager, e não é cerimônia: webhook_lead_captures_manager_read usa fn_role_at_least, enquanto a policy de webhook_events_log é org-flat sem gate de papel — hoje qualquer viewer lê a PII do formulário direto pelo PostgREST com a anon key, mesmo com a rota HTTP exigindo manager. Sem policy de INSERT/UPDATE/DELETE: só o service role escreve. LGPD por TRIGGER, não por 9º passo: fn_lgpd_cascade_redact_contact tem 180 linhas e acrescentar um passo exigiria reescrevê-la inteira no apêndice, criando duas cópias que divergem no primeiro conserto; o gancho é a transição is_anonymized false → true em contacts, que roda na MESMA transação do cascade e alcança QUALQUER caminho que anonimize um contato. Sobre gravar IP: x-forwarded-for é forjável e esta coluna NÃO é material de segurança — nada no produto decide com base nela; ela existe para o dono reconhecer padrão. No stack padrão do kit o header chega (o app não publica porta; quem publica é o Caddy, que faz reverse_proxy app:3000). Aditiva, idempotente, sem constraint sobre dado existente. |
| 20260825130000 | 0175_a_automacao_diz_a_verdade | A automação não tinha como dizer "ainda não". automation_rule_runs.status aceitava success/partial/failed, e faltava o quarto estado que o motor JÁ produz: quando uma ação de envio pede adiamento (postponeUntil — fora da janela do número, cap diário atingido), runAutomationForEvent devolve {status:'retry'} e sai sem gravar linha nenhuma. O evento volta depois e, nesse intervalo, a aba Atividade não mostra absolutamente nada — para quem montou a regra, "não apareceu nada na Atividade" e "a automação não rodou" são a MESMA tela, e foi esse o relato que originou a mudança. É o invariante 4 do Sistema Vivo (nenhuma demanda sem próximo passo) aplicado a uma espera: a espera É um estado, e um estado que ninguém vê é indistinguível de morte. Valor novo em vez de reusar partial: partial significa "algumas ações funcionaram e outras falharam" e a tela pinta de amarelo com o texto "Parcial"; um adiamento não é falha nenhuma — nada foi tentado ainda, e vai ser. Empilhar os dois faria a tela mentir na direção oposta, assustando sobre uma mensagem que só espera o horário. CHECK reconstruído em UM bloco só (lição do #159 registrada no baseline para agent_inbox_items_kind_check: N blocos quebram o update.sh de clone com vocabulário posterior). Aditiva — só ALARGA o conjunto aceito, então nenhuma linha atual passa a violar e não há o que deduplicar antes. |
| 20260826140000 | 0181_o_acervo_e_da_organizacao | A base de conhecimento pertencia a um agente, e o acervo do agente era UMA versão monolítica. Desses dois fatos saía quase todo o resto: material impossível de compartilhar entre assistentes; UM documento por categoria por agente (índice único (agent_id, source_type) WHERE is_active, e upload/route.ts:186 grava source_type='policy' para TODO arquivo — o segundo PDF de qualquer organização colidia com 23505); pipelines competindo pelo mesmo ponteiro (ingestConversationsBatch cria a própria versão e chama activate_kb_version, que DESATIVA a versão de FAQ do mesmo agente, e o worker de FAQ faz o inverso — quem indexou por último apagava o acervo do outro, em silêncio); e apagar o agente apagava a base junto (FK CASCADE). A inversão: a fonte é da ORGANIZAÇÃO, agent_id vira histórico (nullable, ON DELETE SET NULL), e quem lê o quê é ai_agent_versions.knowledge_source_ids uuid[]. Coluna na VERSÃO e não tabela de junção, por duas razões medidas: o runtime lê a config do agente em UMA query sem cache (agent-config.ts, join pelo published_version_id) — junção custaria uma query a mais por turno atendido —, e escopo fora do ciclo rascunho→publicar muda o alcance do agente sem ninguém ter publicado nada (racional escrito da 0125, que esta migration copia inteiro, trigger de imutabilidade incluído). O ponteiro de índice vira POR FONTE (ai_knowledge_sources.active_kb_version_id), que é o que dissolve a competição por construção em vez de por disciplina. As versões legadas continuam válidas sem backfill caro: uma versão antiga contém chunks de VÁRIAS fontes e não há como atribuí-la a uma só — não é preciso, porque o predicado da busca é chunk.kb_version_id = fonte.active_kb_version_id AND chunk.knowledge_source_id = fonte.id, e uma versão compartilhada devolve para cada fonte exatamente os chunks dela; knowledge_source_id na versão fica nullable para sempre (nas novas é proveniência, nas antigas é honestamente desconhecido). O CHECK de source_type sai (precedente da 0127): ele tinha 6 valores com DOIS pares de sinônimos (conversation/conversations, catalog/nuvemshop_catalog) e nenhum valor para documento avulso; o vocabulário vai para lib/ai/rag/tipos-de-fonte.ts e a coluna fica fora do invariante vocabulario-banco-x-typescript, que só cobre colunas que JÁ têm CHECK. A ordem não é detalhe: o índice único (agent_id, source_type) cai ANTES do backfill de vocabulário, senão catalog e nuvemshop_catalog viram os dois catalogo e um agente que tivesse ambos colidiria no meio da migration — quebrando o update.sh do clone, que roda SEM ON_ERROR_STOP, em silêncio. Traz também: last_index_status aceita indexando e sem_credencial (os dois estados que o produto JÁ produz e a tela mostrava como "Não indexado", neutro); arquivar passa a desligar is_active de verdade (nenhuma linha do repo jamais escreveu is_active = false — medido); proveniência do embedding (embedding_model/embedding_dims) na versão, e a busca nova RECUSA a fonte indexada com outro modelo, porque vetores de modelos diferentes não são comparáveis e a busca continuaria respondendo com trecho errado e nota alta; as FKs que faltavam em ai_agents.active_kb_version_id e ai_chunks.kb_version_id (ponteiro pendurado era indistinguível de base vazia: zero chunk, zero erro); e text-embedding-3-small no catálogo ai_models, sem o qual o painel de provedores não tinha o que oferecer para os pontos de embedding. RBAC de brinde e não por acaso: as quatro tabelas de RAG ficaram de fora do aperto da 0150 e ainda tinham policy ALL só-tenancy mais GRANT ALL ... TO anon — um membro papel viewer DELETA a base da própria organização falando direto com o PostgREST, com o JWT dele; entram no par SELECT-tenancy + escrita-com-fn_role_at_least (manager nas duas que a tela edita, admin nas duas que só o motor escreve). Idempotente e auto-curativa: todo dado corrigido ANTES da constraint que o exige. |
| 20260827183000 | 0199_push_subscriptions_rbac | Forward-fix da 0197: push_subscriptions entrava com policy ALL só-tenancy+dono. O gate rbac-config-ia-canais ("nenhuma tabela NOVA entra com policy ALL só-tenancy") reprovou no PR #346. A rota já exige viewer; a policy passa a espelhar com fn_role_at_least(..., 'viewer'), sem alargar quem escreve (continua só a própria linha). Idempotente (drop policy if exists + recreate). |
| 20260826190000 | 0177_agenda_o_compromisso_marcado | O produto sabia quando VOLTAR A FALAR, e não sabia que hora foi COMBINADA com o cliente. "Agendar retorno" já existia — cron_jobs (kind='at', job_kind='followup_turn'), escrito por lib/followup/retorno-crm.ts — mas retorno é decisão interna do sistema: não tem hora marcada com ninguém, não ocupa a agenda de ninguém e o cliente não sabe dele. O que faltava era o oposto: duas pessoas combinaram estar juntas às 14h de quinta — a consulta da clínica, a visita do corretor, a call da agência. Um dono de clínica que instalasse este produto marcava consulta no caderno. Seis tabelas: calendar_event_types (o molde: duração, buffers, aviso mínimo — mudar o molde não reescreve o passado), calendar_appointments (o compromisso), calendar_availability_exceptions, calendar_connections (a conta do Google, BYO), calendar_connection_calendars e calendar_external_events. Mais user_organizations.calendar_color. NENHUMA tabela de jornada semanal, e é a decisão central: ela já existe em attendant_availability.schedule — validada por availabilityScheduleSchema, lida pelo roteamento de conversa (isWithinSchedule) e com tela em AttendantsClient.tsx. Duplicá-la faria o dono de clínica configurar o horário do funcionário em DOIS lugares (anti-pattern nº 2). A agenda LÊ aquela coluna, com outra régua e escrita: windows vazio significa 24/7 para o roteamento e "não publicou horário" para a agenda, porque agenda 24/7 por omissão deixaria marcar consulta às 3h. isWithinSchedule não foi tocada. A ÚNICA tabela nova de disponibilidade é a de exceção por data, que é informação que o jsonb não sabe dar ("dia 12 não atendo", "neste sábado atendo"). Sem coluna lead_id: crm_lead_links.target_kind já aceitava 'appointment' antes desta migration — o lugar do agendamento no polimórfico do CRM estava reservado, e uma FK própria seria um segundo mecanismo de vínculo para o mesmo fato (anti-pattern nº 8). Como target_id é polimórfico e não pode ter FK, o ON DELETE que falta vem de trigger: fn_limpar_vinculos_do_agendamento apaga o vínculo quando o agendamento é apagado. contact_id é FK real com RESTRICT, acompanhando conversations.contact_id e messages.contact_id — as duas únicas RESTRICT do schema, e pela mesma razão: apagar contato não pode apagar histórico. start_minute/end_minute NOT NULL com default (0/1440) em vez de nullable, porque numa UNIQUE o NULL não colide com NULL: dois "dia 12 bloqueado o dia todo" para a mesma pessoa passariam os dois, em silêncio. Dia inteiro é (0,1440), que colide como deve. Sem constraint de sobreposição de horário: exigiria btree_gist, medido ausente tanto no baseline (que só cria pgcrypto) quanto no prelude de scripts/test-db.sh — a constraint quebraria o install de todo clone. Quem impede overbooking acidental é o motor de slots, onde a regra pode ter exceção; o banco guarda o fato. Vocabulários espelhados, não inventados: calendar_connections.status são os SETE valores de tenant_integrations_status_check (incluindo rate_limited, que é o estado que uma API de calendário mais produz); created_by_kind segue crm_lead_activities.actor_kind (user/ai/system/contact) em vez do par human/agent da outra convenção do repo, porque é na timeline do lead que essa autoria é renderizada — duas palavras para a mesma pessoa seria a tela mentindo; + sync, que não é ator do produto e sim "a linha nasceu de evento que já estava na agenda externa". Cada CHECK de vocabulário é uma constraint DEDICADA (a regra de negócio do cancelamento vai em constraint separada), porque duas constraints casando col in (...) na mesma coluna fazem o extrator do invariante de vocabulário se recusar a escolher. RLS: cinco org-flat com fn_user_org_ids() + fn_is_platform_admin() — a agenda de uma clínica é de quem trabalha nela, e o requisito é filtro POR pessoa, não sigilo ENTRE pessoas. calendar_connections é a exceção e leva gate: lê o dono da conexão ou manager+, porque guarda token OAuth e o PostgREST também serve a tabela com a anon key. Escrita dela não tem policy nenhuma: quem grava token é o callback e o worker de renovação, ambos com service role. Todas com revoke all ... from anon. Aditiva e idempotente: seis tabelas novas e uma coluna nova nullable. Nenhuma linha atual passa a violar nada — não há o que deduplicar antes da constraint. |
| 20260826200000 | 0182_dois_cliques_nao_marcam_duas_vezes | Entre a validação do motor de slots e o INSERT há uma janela, e dois POSTs cabem nela. O motor decide se o horário está livre ANTES de gravar; entre aquela pergunta e o INSERT o banco não repete a pergunta, então dois POSTs simultâneos para o mesmo horário passam OS DOIS e criam dois agendamentos. É o duplo clique da recepcionista e a corrida entre duas pessoas marcando o mesmo slot — o acidental que o motor NÃO vê, porque ele validou e estava certo quando validou. Não é a constraint de sobreposição que a 0177 recusou, e a distinção é o que faz esta passar onde aquela não passava: exclude using gist proibiria SOBREPOSIÇÃO (14h-15h contra 14h30-15h30, que é o encaixe que uma recepção faz todo dia) e exigiria btree_gist, medido ausente no baseline e no prelude de scripts/test-db.sh — quebraria o install de todo clone. Índice único parcial é btree puro e proíbe só a COINCIDÊNCIA EXATA de instante para o mesmo dono. owner_user_id is not null NÃO conserta buraco, e eu afirmei que sim antes de medir. Medido num pg17 descartável, os dois índices lado a lado: com a condição e sem ela, duas linhas com dono NULL no mesmo instante entram IGUAL — NULL nunca colide com NULL numa UNIQUE, esteja a linha dentro ou fora do índice; o controle com dono real recusa a segunda com 23505. A condição fica por duas razões reais: mantém fora do índice o que nunca colidiria, e DECLARA o alcance da guarda (ela é sobre a agenda de uma PESSOA, e agendamento sem atendente não ocupa a de ninguém). É documentação executável, não proteção. Distinta do caso da 0177, onde start_minute virou NOT NULL e ali o comportamento muda de verdade — o valor passa a existir e a colidir. O custo, escrito para ninguém descobrir sozinho: proíbe dois compromissos do mesmo atendente no mesmo instante, e numa clínica isso às vezes É o encaixe deliberado. A troca é consciente, mas o 23505 não pode virar erro genérico: a rota devolve 409 dizendo QUAL compromisso está ali e oferecendo o caminho — invariante 4 do Sistema Vivo. Sem isso troca-se uma corrida rara por uma parede diária. Deduplica ANTES da constraint, e deslocando em vez de apagar: índice único falha se os dados já o violam, e num clone isso quebraria o update.sh, que roda sem ON_ERROR_STOP e filtra erro por texto. O bloco empurra a 2ª/3ª ocorrência em 1 segundo cada, levando ends_at junto para a duração não mudar, em laço com teto de 10 passadas. Cancelar a duplicata apagaria um compromisso combinado com uma pessoa real, e isso uma migration não faz. Nenhum clone tem linha nesta tabela hoje — nasceu na 0177 e a rota que grava não existe. |
| 20260826210000 | 0183_a_grade_da_agenda_nao_se_move_sozinha | A grade não atualizava sozinha, e o diagnóstico ia para o lugar errado. calendar_appointments não estava na publication supabase_realtime: o .channel() sobe, o subscribe devolve SUBSCRIBED, nenhum erro em lugar nenhum, e nenhum evento chega nunca — duas pessoas com a agenda aberta não veem o que a outra marcou, e a tela parada é indistinguível de "ninguém marcou nada". O agravante é de diagnóstico: nesta base o canal já morre calado por OUTRO motivo quando o token não chega ao socket, então quem investigar vai direto para o setAuth, onde o defeito esteve antes, e não para a publicação, onde ele está agora — dois defeitos com o MESMO sintoma fazem o segundo custar o dobro. Só calendar_appointments entra, e a doutrina julga tabela a tabela (as palavras são do próprio schema: crm_lead_scores ficou fora porque "recálculo é telemetria e não deve pintar card"): calendar_external_events é espelho reescrito em lote pelo sync, e 200 eventos do Google virariam 200 pulsos — o "pulso que mente" da 0075 em forma de calendário; calendar_connections guarda token OAuth; as de configuração mudam com quem já está na tela que recarrega. O que esta migration NÃO resolve, declarado para não ser descoberto em produção: replica identity tem ZERO ocorrência neste schema, então o payload de DELETE traz só o id — um canal que assine com filter: owner_user_id=eq.<uuid> não recebe o DELETE, e o card do compromisso apagado fica na tela até o F5. As tabelas já publicadas convivem com isso porque seus consumidores invalidam a query inteira em vez de aplicar o payload. Não liguei replica identity full porque hoje não existe assinante — app/app/agenda/_client.tsx tem zero ocorrência de realtime —, e o custo em WAL seria para servir um consumidor inexistente; a decisão de COMO a tela lida com o DELETE é de quem escrever o hook. Aditiva e idempotente, com a guarda de pg_publication_tables que o próprio baseline usa. |
| 20260826220000 | 0184_anonimizar_um_contato_deixava_a_agenda_legivel | Anonimizar um contato reportava SUCESSO e deixava a queixa clínica legível. fn_lgpd_cascade_redact_contact percorre uma lista de tabelas escrita à mão e calendar_appointments não estava nela — e ela guarda, em texto livre, title ("Consulta — Maria Silva"), description, notes (a anotação do atendimento, que numa clínica é queixa clínica), location_details (o endereço de uma visita) e cancellation_reason. O que torna isto grave não é o esquecimento, é a FORMA do silêncio: a função devolve contagem por tabela, a rota reporta sucesso, o SLA de D+15 é marcado como cumprido — nada erra, nada loga, e o titular recebe a confirmação de que seus dados foram anonimizados enquanto a queixa dele continua legível no banco, com hora e endereço. E o on delete restrict de contact_id não protege nada aqui, porque a LGPD deste produto ANONIMIZA em vez de apagar: o contato vira "Cliente Anonimizado #N" e as linhas da agenda seguem intactas. Trigger, e não um passo dentro da função: ela vem do pg_dump com ~180 linhas, e acrescentar um passo exigiria carregar uma CÓPIA inteira dela no apêndice — duas cópias que divergem no primeiro conserto de qualquer uma. O gancho em uso é after update of is_anonymized on contacts, o mesmo da 0174, e ele roda na MESMA transação do cascade. E há uma razão que vale mais que a economia de linhas: o trigger escuta a COLUNA, não o chamador, então alcança qualquer caminho de anonimização — o que importa porque existe mais de um neste repo. Redige o texto livre e PRESERVA starts_at, ends_at, status, event_type_id e owner_user_id: a doutrina manda preservar timestamps, e a razão vale aqui — a clínica precisa responder "quantos atendimentos houve em março" depois de anonimizar, e isso é registro de operação, não dado pessoal. O QUE aconteceu e QUANDO fica; COM QUEM e SOBRE O QUÊ sai. calendar_external_events fica de FORA, e não por esquecimento: ela não tem contact_id, e o único vínculo com a pessoa é o title copiado do Google — não há predicado que a alcance a partir do contato anonimizado. Alcançá-la exige decidir entre declarar por escrito que é espelho de sistema de terceiro (e então o EXPORT precisa dizer isso ao titular) ou apagar a janela inteira daquela conexão. Registrado como pendência com dono, não como omissão silenciosa. Aditiva: função e trigger novos, nenhuma linha existente muda ao aplicar. |
| 20260826230000 | 0185_instalacao_fresca_nao_dava_para_marcar_nada | Instalação fresca abria a Agenda e não dava para marcar nada. Zero INSERT em calendar_event_types em todo o repo — medido em lib/, app/, scripts/ e supabase/, com controle positivo (a mesma sonda contra crm_stages acha 3 lugares no SQL e 31 no TypeScript). O vocabulário de categorias existia desde a 0177 e ninguém escrevia linha nenhuma. E não dava erro: a grade vazia é indistinguível de "ninguém marcou hoje" — quem instala numa VPS abre a Agenda, vê uma semana em branco e não tem o que clicar, sem mensagem e sem próximo passo. É o caso P0 da doutrina de QA Visual e o item 7 do pedido do dono do produto. TRIGGER e BACKFILL, e não um só: medido, o baseline NUNCA semeia organização que ainda não existe — o que ele faz é backfill das de hoje, e o único mecanismo que alcança organização FUTURA é trigger em organizations (há exatamente um no schema, o do funil). Só backfill deixaria a segunda organização criada amanhã com a agenda vazia; só trigger deixaria sem nada todo clone que já instalou. NEUTRO de propósito, e é decisão, não preguiça: três tipos que servem a qualquer negócio (Consulta, Reunião, Atendimento). O nicho não é persistido em lugar nenhum — escolherPacotePorTexto() roda em memória no passo do funil e o resultado morre ali, e o que fica em onboarding_state.funil é {pipeline_id, origem, etapas}, sem o id do pacote. Além disso o trigger dispara no INSERT da organização, antes de existir texto para inferir: o nome é tudo o que há e welcome.o_que_faz ainda não foi preenchido. Este é o PISO, não o teto — o enriquecimento por nicho, que é o que o item 7 pede de verdade, vive onde o nicho EXISTE, em app/actions/onboarding/montarQuadro.ts, e entra em commit próprio. Os dois não competem. on conflict do nothing, nunca do update: o update.sh re-aplica o baseline.sql inteiro a cada atualização, e com do update o tipo que o dono JÁ editou — renomeado, com outra duração — seria sobrescrito a cada versão do produto, em silêncio. É a diferença entre semear e mandar. default_owner_user_id fica NULL e é deliberado: o trigger dispara no INSERT de organizations e o bootstrap-owner.ts só escreve em user_organizations depois — não há a quem apontar, e a FK é on delete set null. Aditiva e idempotente: funções novas, trigger novo e backfill guardado por not exists. |
| 20260827000000 | 0186_a_cor_da_pessoa_e_uma_trilha_nao_um_hex | A 0177 criou duas colunas de cor guardando hex, e as duas estavam erradas. O argumento que as derruba é do @VPS, escrito no cabeçalho de components/agenda/paleta.ts: hex guardado é "um segundo lugar para a mesma verdade, e o tema escuro fica de fora". Medido: as cores vivem em app/globals.css como --agenda-pessoa-1..8, em TRÊS blocos de tema, e a MESMA trilha tem hex diferente em cada um — a trilha 1 é #ac4d40 num bloco e #f89080 noutro. Um hex no banco não tem como ser as duas coisas: nasce sem tema escuro, e a tela ou ignora o valor do cliente ou perde o tema. Por que AGORA: zero consumidores das duas colunas, medido com controle positivo (a mesma sonda acha trilha em 34 arquivos, então estava viva). Trocar hoje custa esta migration; trocar depois que a rota de marcar gravar custa migração de dado de cliente — a janela fecha quando o POST nascer. calendar_color vira calendar_trilha (smallint 1..8) e a escolha manual FICA: trilhaPadraoDoMembro() deriva uma trilha estável do user_id, mas a derivação COLIDE para alguns pares (oito trilhas, mais de oito pessoas) e quem administra vai querer desempatar. NULL = use a derivada. calendar_event_types.color SAI, e não é só por falta de consumidor: há UM pixel por compromisso na grade, e duas colorações competindo pelo mesmo lugar significam que uma delas mente — ou a faixa diz de quem é o compromisso, ou diz que tipo ele é, e o olho não lê as duas. O item 10 do pedido diz cor POR PESSOA. Se um dia alguém quiser colorir por tipo, volta como trilha também, com um alternador que torne as duas mutuamente exclusivas. ⚠️ DROP COLUMN é destrutivo e não foi escrito de leve. O que autoriza: as colunas nasceram na 0177 no mesmo dia, o seed da 0185 não preenche nenhuma das duas, e a varredura por consumidor devolveu zero em lib, app, components, hooks, workers e tests. Não há dado de cliente a perder porque não existe caminho que grave. |
| 20260827010000 | 0187_o_espelho_do_google_e_cache_com_prazo | Espelho declarado sem prazo vira arquivo permanente com outro nome. A 0184 deixou calendar_external_events FORA da cascata de LGPD por ela não ter contact_id — o único vínculo com a pessoa é o title copiado do Google, e apagar por conexão destruiria dado de terceiros que não pediram nada. A decisão de produto foi declarar ESPELHO: a fonte da verdade é a agenda do Google do próprio cliente, onde o titular exerce o direito com o controlador de lá. Mas essa declaração só é honesta com TRÊS propriedades, e a terceira faltava: (1) reconstruível — o sync repõe; (2) some quando a conexão sai — já verdade, connection_id é on delete cascade desde a 0177; (3) tem prazo. Sem a terceira, "espelho" é só um nome mais simpático para um arquivo permanente de compromissos de terceiros, guardado por um produto que declarou não ser o controlador daquele dado. Corta por ends_at, NUNCA por created_at: um compromisso futuro não envelhece por mais antigo que seja o registro dele, e apagar pelo created_at — que é o que a poda de auditoria faz — removeria um evento marcado com um ano de antecedência antes de ele acontecer, fazendo a agenda marcar em cima de hora ocupada. Piso de 7 dias e não os 90 da auditoria: auditoria é rastro que existe para ser consultado depois de um incidente, e piso alto impede que o knob vire apagador de rastro; este espelho é cache, e quem quiser mais passado pede ao sync. Piso alto aqui não protegeria ninguém — só guardaria mais tempo dado de terceiro. Ligada ao cron data-retention que já existe, no mesmo molde das outras duas podas, com knob CALENDAR_MIRROR_RETENTION_DAYS. E houveEfeito() passou a contar a terceira: sem isso, uma rodada que só podasse o espelho apagaria linhas sem deixar registro — e a doutrina do repo é auditar QUANDO HÁ EFEITO, nunca parar de auditar. Aditiva: função e índice novos, nenhuma linha apagada pela aplicação da migration. |
| 20260827040000 | 0190_o_mesmo_state_do_google_valia_duas_vezes | O state do OAuth do Google valia duas vezes dentro do prazo. Ele é assinado com HMAC e expira em dez minutos, mas o nonce era emitido e jogado fora — dívida declarada em lib/agenda/google/estado.ts desde que o arquivo nasceu, e provada POR EXECUÇÃO na verificação independente. Entra calendar_oauth_nonces, com o nonce como chave primária: a segunda tentativa viola a unicidade, e é assim que o replay é recusado. TABELA e não Redis, e a razão é falhar fechado: Postgres é o único armazenamento garantido em TODA instalação e o Upstash é opcional no self-host — propriedade de segurança que degrada em silêncio onde a dependência opcional falta é pior que propriedade nenhuma, porque a instalação sem Redis PARECERIA protegida. Fecha replay e SÓ: a outra porta, um state válido apresentado por outra pessoa, é fechada pela leitura da sessão no callback. São portas diferentes, e durante horas a dívida do nonce deu a impressão de cobrir as duas. Traz junto fn_expurgar_nonces_de_oauth, quarta poda do cron data-retention, com piso no corpo e os dois revoke que função nova em public exige. Sem ela a tabela cresceria para sempre — uma linha por conexão tentada, num produto que ninguém monitora. |
| 20260827030000 | 0189_o_espelho_nao_se_limpa_sozinho | A poda por prazo não fecha o fantasma, e quem ler a 0187 vai presumir que sim. Ela deu prazo ao espelho e o comment on table passou a dizer "cache com prazo" — está certo e é insuficiente. O caso que a poda NÃO alcança: um evento com ends_at no FUTURO, de uma conexão VIVA, que foi apagado no Google. Ele nunca envelhece, porque o corte é ends_at < now() - N dias e futuro não vence nunca. Fica no espelho para sempre, ocupando um horário que na agenda do cliente já está livre — e o sintoma é o oposto do que a poda protege: em vez de dado velho demais, é dado que deveria ter sumido e não some, fazendo a agenda RECUSAR uma hora que existe. Quem limpa isso é a RECONCILIAÇÃO do sync (remover o que não veio na resposta da janela), que é da frente do Google e não existe hoje. Não é defeito da 0187 — é o LIMITE dela, e o limite precisa estar escrito onde a promessa está, senão a promessa engana. Ressalva levantada pelo maestro. Por que merece migration em vez de linha de doc: o comment on table é o que um DBA lê no \d+ e é a única declaração que viaja junto com o schema para todo clone; uma ressalva que fica só no briefing morre com a entrega. Aditiva: só reescreve um comentário, nenhuma linha de dado é tocada. |
| 20260827020000 | 0188_a_volta_do_google_sem_identidade_cria_fantasma | A volta do Google não tinha identidade, e sem ela o mesmo compromisso bloqueia dois horários. calendar_appointments.google_ical_uid existe desde a 0177 — o lado de IDA sabe qual evento do Google é nosso. A linha de VOLTA não guardava equivalente: external_event_id é o id do Google, não a nossa identidade. Medido: calendar_external_events tinha 14 colunas e nenhuma de uid, nem no create table nem em alter posterior. Duas consequências já pagas: (1) um agendamento nosso que a pessoa MOVE no Google volta pelo sync e ocupa o horário NOVO, enquanto calendar_appointments segue ocupando o ANTIGO — o mesmo compromisso bloqueando dois horários, sem nada que os ligue para desfazer; (2) o cron do "aconteceu?" não consegue perguntar se o evento foi cancelado do lado de lá a não ser por heurística de mesmo dono e mesma janela, cujo falso positivo é DESTRUTIVO — cancelaria um compromisso real porque outro evento na mesma janela foi cancelado. O passo está barrado no handler esperando esta coluna. Destrava também o anti-eco: ehIcalUidNosso deixa de ser função pura sem consumidor e passa a filtrar na escrita, para não reimportarmos como alheio o evento que nós mesmos criamos. Aditiva e idempotente: coluna anulável, sem default e sem constraint, mais índice parcial em (organization_id, ical_uid) where ical_uid is not null. |
| 20260827050000 | 0191_o_playbook_de_agendamento_nao_conhecia_as_ferramentas | O playbook semeado mandava o CONTRÁRIO da ferramenta, e os dois chegam ao modelo na mesma janela. O corpo de agendamento vem da 0069, anterior às ferramentas de agenda: no passo 5 ele manda "cancele/substitua o anterior", enquanto a description de crm_reschedule_appointment diz que remarcar é o MESMO compromisso mudando de hora. serializeStablePrefix serializa tools E system juntos — o system é o corpo do playbook —, então não há como prever qual vence, e o gatilho que injeta esse playbook é a própria palavra "remarcar". Corpo novo (98 linhas, 5507 caracteres) com a Regra de ouro virando critério verificável (você tem acesso se e somente se crm_find_free_slots existir — não julgue por intuição, chame), passo 2 condicionado à ferramenta, passo 5 resolvido nos DOIS ramos, e a separação entre marcar consulta e agendar retorno. Nenhum ramo foi apagado: o playbook é lido por quem tem as ferramentas e por quem não tem, e no dia seguinte ao merge a maioria não tem; o que muda é que o agente deixa de ADIVINHAR em qual ramo está, e o ramo sem ferramenta passa a declarar o custo que sempre teve ("você pode receber dois avisos"). A mecânica é o que esta migration tem de diferente, e ela NÃO copia a 0069. Aquela publica sob if not exists (select 1 from skill_pointers ... name=...), que é correto para SEED e é o pior desfecho possível para PUBLICAR VERSÃO: todo clone instalado já tem o ponteiro, o bloco vira no-op, a migration passa VERDE e ninguém recebe o corpo novo — o defeito que este projeto já pagou ("o prompt vem da versão publicada"). Medido num pg17 efêmero: aplicar o corpo novo na forma da 0069 sobre um banco que já tem a 0069 deixa 1 versão e o ponteiro no corpo ANTIGO, sem erro nenhum. Aqui a idempotência é por CONTEÚDO (md5 do corpo, porque skill_versions não tem índice único e o update.sh re-aplica este baseline a cada atualização), o md5 declarado é CONFERIDO contra o que acabou de ser inserido (raise exception se divergir, em vez de uma segunda linha silenciosa), e o repointe roda SEMPRE — update, insert só se não achou linha. Provado: 0069 → 1 versão/ponteiro antigo; este bloco → 2 versões/ponteiro NOVO; 2ª e 3ª aplicação → segue 2 versões e o mesmo ponteiro. A forma values (null, '<nome>', ..., $body$...$body$, ...) não é estilo: tests/unit/playbook-cita-a-ferramenta.test.ts extrai os corpos semeados por esse padrão exato, e trocar o delimitador faz o gate ficar verde por vacuidade — sabotado e medido. Aditiva: nenhuma linha existente é apagada, a versão antiga continua em skill_versions (imutável por trigger) e só o ponteiro anda. |
| 20260827080000 | 0193_a_conexao_do_google_sem_fk_e_sem_fuso | Duas ausências da 0177, achadas por varredura e não por leitura — e as duas aparecem como linha AUSENTE, não errada. (1) google_connection_id era a ÚNICA das nove colunas-ponteiro de calendar_appointments sem references: as outras oito trazem FK com on delete explícito e comentário justificando. Anti-pattern nº 4 do CLAUDE.md, e hoje sem custo porque o escritor de ida ainda não nasceu — custa no dia em que nascer, e aí o órfão é construtível. set null e não cascade: conexão do Google revogada NÃO apaga compromisso, que existe no CRM por direito próprio; apagá-lo seria cascade fantasma (anti-pattern nº 7) destruindo histórico por causa de uma integração. (2) O fuso vinha do Google, era gasto como metadado de auditoria e DESCARTADO por não haver coluna: o sync cravava fuso: null e o fallback ?? 'UTC' disparava SEMPRE. Em America/Sao_Paulo um evento de dia inteiro bloqueia das 21h do dia anterior às 21h do seguinte — a noite do próprio dia vaza. A coluna vai em calendar_connection_calendars e não em calendar_connections porque o Google devolve timeZone POR CALENDÁRIO: guardar na conexão achataria N em 1, e uma conexão com dois calendários em fusos diferentes passaria a mentir sobre um deles. O comentário da coluna manda tratar NULL como "não sei", nunca como UTC — foi exatamente o ?? UTC que produziu o vazamento. Por que forward-fix em vez de editar a 0177: ela já vive em QUATORZE branches com o baseline aplicado, e o create table do baseline é if not exists — banco já criado não recebe coluna por reescrita do create, o statement vira no-op. Editar a 0177 não alcançaria nenhuma das quatorze; o ponto sem volta desta mudança passou quando ela entrou na integração, não quando entrar na main. Aditiva e idempotente: colunas anuláveis, constraint sob guarda de pg_constraint, e backfill que anula ponteiro morto ANTES de criar a FK — senão o update.sh de um clone adiantado quebraria no meio, e ele roda SEM ON_ERROR_STOP. O apêndice entra ANTES do bloco da VARREDURA anon: não cria função e não precisaria da cura, mas apêndice depois dela recria a erosão que a 0192 acabou de consertar. |
| 20260827090000 | 0194_lembrete_nasce_desligado | O lembrete nascia LIGADO, e quem escolheu foi o default. reminder_enabled veio default true na 0177. Medido: ZERO leitores fora do database.types.ts (idem reminder_minutes_before), e o scheduler não tem cron de lembrete — controle da mesma sonda: event_type_id aparece em 9 arquivos, então o instrumento enxerga colunas desta entrega sendo lidas. O perigo não é o envio de hoje — não há envio. É a ORDEM DOS EVENTOS: no dia em que o disparador nascer, ele lê a coluna, e toda linha criada antes, em toda instalação, já estará true. A chave chega PRÉ-LIGADA para o histórico inteiro, escolhida pelo default e não por gente. Enviar mensagem é irreversível e nunca é operação comum. "Não há quem dispare" é razão para não ser urgente, nunca para não ser defeito — e é o que torna a correção barata: uma palavra agora, contra data migration sobre linhas que o operador já mexeu. O update das linhas existentes só é seguro AGORA, por não haver leitor; depois seria apagar escolha alheia. Ligar por padrão volta a ser decisão do dono quando o disparador existir. Achado por varredura do QA; a premissa que eu havia levantado ("instalar a agenda dispara WhatsApp") era FALSA e foi ele quem a derrubou com número. |
| 20260827100000 | 0195_tipo_semeado_nascia_sem_dono | Os três tipos que o produto semeia nasciam sem dono, e sem dono não há agenda. fn_semear_tipos_de_agendamento nunca definia default_owner_user_id, e a consulta de horários EXIGE dono — sem ele devolve sem_responsavel. Medido no caminho real pela cerca agenda-marcar-pela-tela: 422 três vezes, "Atendimento não tem responsável definido". Toda org nova nascia com três tipos decorativos: o usuário abre a Agenda numa instalação fresca, clica em Novo agendamento e não há horário. Nunca. O seed não podia resolver sozinho: o trigger é after insert on organizations, e naquele instante user_organizations ainda está vazia para a org — não há dono a escolher; a função não estava errada, estava CEDO. A saída é o outro lado do tempo: adotar quando o PRIMEIRO membro chega, e só o primeiro — se preenchesse a cada membro novo, um tipo que o operador deixou sem dono de propósito voltaria a ganhar um, e o produto desfaria escolha de gente. revoked_at is null nas duas metades do backfill: a tabela guarda o ex-membro em vez de apagá-lo, e sem o filtro o dono padrão da agenda poderia ser alguém que já saiu. Aditiva: função nova, trigger novo, e backfill que só toca linha com dono NULO. Achado pelo QA ao escrever a cerca; a metade da tela é dele (b41c66ea). |
| 20260827190000 | 0200_o_que_ainda_nao_foi_ao_google | O worker que empurra compromisso para o Google nunca empurrou nada — em instalação nenhuma. agenda-google-push pedia os pendentes com .or("google_synced_at.is.null,updated_at.gt.google_synced_at"), e o PostgREST trata o lado DIREITO de gt. como VALOR LITERAL: ele tenta converter a string "google_synced_at" em timestamptz e recusa a consulta INTEIRA. Medido no log de produção da v1.7.0: invalid input syntax for type timestamp with time zone: "google_synced_at", um warn a cada 5 minutos desde o deploy, zero linhas lidas. Não era "o filtro pegava demais" — era nenhuma linha, nunca. O teste unitário ficou verde porque guardava a CHAMADA, não o EFEITO: o dublê aceita qualquer string de filtro, e uma string que o Postgres recusa é indistinguível de uma que ele aceita. A coluna gerada dá ao PostgREST um filtro que ele sabe fazer, e vem com a cerca que faltava — um invariante que roda contra Postgres REAL e reprova filtro cujo lado direito é nome de coluna da própria tabela. Escolhida sobre RPC porque o valor é derivado de duas colunas da mesma linha (mesmo argumento de contacts.wa_identity) e porque RPC nova traria a obrigação de revogar execute das duas origens. Segura porque foi medida: os 11 sítios que tocam calendar_appointments foram lidos e nenhum faz upsert de linha inteira — este repo já quebrou escrevendo em coluna GENERATED. Aditiva: coluna nova e índice parcial, sem dado a curar. |
| 20260827200000 | 0201_credencial_do_google_pela_tela | Conectar o Google Agenda exigia SSH na VPS e editar o .env. As credenciais do app OAuth tinham UMA fonte: process.env, lida no boot. O produto é self-host para quem NÃO programa — nomear GOOGLE_CALENDAR_CLIENT_ID para essa pessoa é o mesmo que dizer que a funcionalidade não existe. Singleton de INSTALAÇÃO e não de organização, com argumento: o redirect_uri sai de NEXT_PUBLIC_APP_URL, o install.sh grava o par no .env da VPS, e o app OAuth é registrado no console do Google pelo dono da instalação — é uma VPS por cliente. Clone declarado do molde de platform_branding (0155), inclusive na proteção: RLS ligada com ZERO policies e grants revogados de anon/authenticated, então o PostgREST não serve a tabela. Isso não é excesso — a anon key vai para o browser, e o client_secret permite trocar códigos e refresh tokens em nome da instalação, isto é, ler a agenda de todos os atendentes que conectaram. Reusa fn_encrypt_oauth/fn_decrypt_oauth (0041), a MESMA cifra que o callback do Google já usa para os tokens: nenhuma função nova em public, logo nenhuma superfície security definer nova. A precedência é banco→env (precedente zernio/credentials.ts), então o .env continua sendo piso de rollback. Aditiva: tabela nova, sem dado a curar. |
| 20260827060000 | 0192_a_poda_de_nonces_esqueceu_authenticated | fn_expurgar_nonces_de_oauth revogava só de public, anon — as duas irmãs de assinatura idêntica (fn_expurgar_auditoria_vencida, fn_expurgar_espelho_da_agenda) revogam também de authenticated. O grant vem do ALTER DEFAULT PRIVILEGES ... TO authenticated do baseline, então a falha é uma linha AUSENTE, não errada: qualquer usuário logado de qualquer org podia apagar, pelo PostgREST, os nonces de OAuth de TODOS os tenants (a definer não tem recorte por org — não precisa: o único chamador é o cron de retenção com service role). Não vaza dado, só apaga — e é por isso que passou na revisão. Achado pela varredura de tests/invariants/hardening-definer-varredura.test.ts, não por leitura. Forward-fix da 0190. |
| 20260828000000 | 0202_o_nome_do_atendente_custava_uma_chamada_http | Toda listagem do Inbox pagava 1 requisição HTTP ao GoTrue Admin API por atendente único da página. comNomeDoAtendente() → nomesDosAtendentes() (lib/users/nome-do-atendente.ts) resolve o nome de quem atende chamando admin.auth.admin.getUserById — o próprio arquivo já media o custo (~60ms para 1, ~350ms para 10, ~1,2s para 50 atendentes únicos) e já apontava o conserto: desnormalizar, como o repo já faz em conversation_notes.created_by_name. Entra em fn_conversation_assign, e não em 4 arquivos TS: toda atribuição de conversa — claim, release, transfer, roteamento automático — passa por esta ÚNICA função SECURITY DEFINER; não existe UPDATE direto de assigned_to_user_id em nenhum outro lugar do repo. conversations.assigned_to_user_name (nullable) é gravada no MESMO update que grava o id, lida de auth.users.raw_user_meta_data->>'full_name' (mesmo path de admin/users/route.ts e admin/platform-admins/route.ts), e zerada junto quando a atribuição sai (release). Assinatura IDÊNTICA de propósito — parâmetro novo criaria overload e as chamadas por nome existentes passariam a falhar com is not unique. O app-side (comNomeDoAtendente) passa a ler a coluna já presente na linha, com nomesDosAtendentes() mantida só como fallback do caso raro em que o id está preenchido e o nome não (linha de antes desta migration, backfill que não alcançou). Aditiva e idempotente: coluna nullable com if not exists, backfill guardado por where assigned_to_user_name is null, create or replace function. |
| 20260831000000 | 0203_comando_da_conversa | O banco passa a saber QUEM MANDA na conversa. fn_comando_da_conversa (a regra, immutable, com p_agora parametrizado para o teste de espelho ter relógio fixo) e comando_da_conversa(conversations) (campo calculado que o PostgREST publica em ?select= e em ?comando_da_conversa=in.(...)). PORQUÊ: as abas da Inbox descreviam quem manda lendo conversations.status, coluna que o motor de IA nunca lê — medido na VPS, a aba IA mostrava 2 e a Fila 83 enquanto o motor atendia 49. O filtro precisa de contacts.force_human/is_blocked (outra tabela), então reescrevê-lo no query builder seria a regra em duas encarnações. Casadas por tests/invariants/comando-da-conversa-espelha-o-ts.test.ts. Invoker de propósito (respeita RLS de quem pergunta), e por isso a varredura anon do fim do baseline não as alcança — revoke/grant explícitos. |
| 20260901120000 | 0204_catalogo_de_produtos_da_loja | O catálogo que a LOJA possui — a tabela que faltava para o agente de IA responder preço EXATO. Até aqui a única tabela de produto do schema era nuvemshop_products, que é ESPELHO de loja remota: medido, ela não tem UM escritor no repositório inteiro e o indexador do RAG nem a lê (vai à API da Nuvemshop direto). Uma loja física sem Nuvemshop não tinha onde guardar preço como DADO — sobrava o RAG, e preço em busca semântica é armadilha conhecida: o trecho de 'iPhone 15 Pro 256GB' casa com o do 'iPhone 15 128GB' e o agente responde o valor errado. Tabela NOVA e não nuvemshop_products + coluna origem, por quatro razões em ordem de força: (1) a policy daquela tabela é for all org-flat sem fn_role_at_least — qualquer viewer reescreve preço pelo PostgREST com o JWT dele —, e ela está congelada na allowlist de dívida de RBAC, que é fechada para tabela nova; (2) external_id, last_updated_at e payload são not null de espelho, e produto digitado à mão teria de inventar os três, com unique(org, external_id) impondo chave falsa; (3) dois donos de escrita na mesma tabela — no dia em que o sync ganhar o escritor que lhe falta, um where origem = 'nuvemshop' esquecido apaga o catálogo digitado à mão; (4) o nome mente em instalação sem Nuvemshop. Uma linha por item VENDÁVEL, não pai+variações: quem tem preço é o SKU, e um pai sem preço não responde ao cliente (DIRC letra C — o 'modelo' é derivável de marca+categoria+nome). controla_estoque conserta uma armadilha da tool antiga, que filtra available_qty > 0 por default e deixaria invisível o catálogo de quem não conta estoque (decant, sob encomenda). RLS no molde da 0177: leitura para a org, escrita só de manager para cima. Índice GIN trigram em nome para a parte difusa da busca por token (ver lib/catalogo/busca.ts). |
| 20260902020000 | 0205_um_agente_indexa_mais_de_um_material | Um agente só conseguia ter UM material indexado — para sempre, e em silêncio. A migration 0181 mudou a semântica do número da versão de acervo: passou a ser "a quantas indexações DESTE material", contado por knowledge_source_id — é o que lib/ai/rag/version.ts faz e o que o comentário dele diz. O índice único não acompanhou: seguiu em (agent_id, version_number). Como toda fonte nova nasce com version_number = 1, a SEGUNDA fonte do mesmo agente colidia com a primeira. Não é corrida, é determinístico. Medido em produção: cinco materiais subidos para o mesmo agente, um indexou com 14 trechos e quatro pararam em pending com attempts=2; a tela mostrava os cinco como ready, com chunks_count = 0. Dois índices parciais e não um: as versões anteriores à 0181 têm knowledge_source_id NULL e continuam válidas, e um índice puro por fonte as deixaria SEM restrição nenhuma (em Postgres NULL não colide com NULL) — o que afrouxaria um invariante que hoje vale. Cada regime guarda o seu. A dedup vem ANTES do índice porque um clone com duplicata do regime novo quebraria o update.sh no meio. |
| 20260903143000 | 0210_tarefas_do_crm | O produto não tinha onde guardar "ligar de volta na terça". Lembrete de trabalho interno com prazo vivia na cabeça de quem atende — ou em custom_fields do lead, jsonb sem schema, sem índice por prazo e invisível para qualquer lista que pergunte "o que vence hoje". Tabela nova e não a Agenda (0177): calendar_appointments marca COMPROMISSO COM O CLIENTE, com horário, local, convidado e confirmação; uma tarefa não tem nada disso e pode nem ter dono — pôr as duas na mesma tabela obrigaria a inventar convidado e local para cada lembrete. due_date é timestamptz e nullable: "algum dia eu preciso" é tarefa legítima, e forçar data faria o operador inventar uma, envenenando a lista de atrasadas — que é a única razão de a coluna existir. lead_id/contact_id são on delete set null, não cascade: apagar um funil não pode apagar o que a pessoa escreveu para si mesma. assigned_to/created_by são FK para auth.users, não texto (anti-pattern nº 1). Duas policies e não uma tenant_isolation_*_all: uma policy for all org-flat deixaria qualquer viewer apagar tarefa dos outros pelo PostgREST com o JWT dele — a mesma crítica que a 0204 faz a nuvemshop_products_tenant. Leitura para a organização; escrita a partir de agent, o papel de quem atende. Extraída do PR #418 (@clinicacentrodosorrisosc-code): lá a tabela existia só no baseline.sql, sem arquivo de migration — o que a deixaria fora de qualquer clone que aplique migrations/. |
| 20260904050000 | 0212_o_email_do_convidado | O agendamento marcava a hora e não convidava ninguém. calendar_appointments.guest_email (text, nullable) captura o e-mail de um convidado externo no formulário de novo agendamento; quando presente, ele vira attendees no evento do Google Calendar e o convite sai por e-mail (sendUpdates=all na chamada de escrita). Nulo mantém o comportamento anterior — evento sem convidado — então a coluna é aditiva e nenhum agendamento existente muda de significado. Idempotente (add column if not exists). |
| 20260904051000 | 0213_conversoes_de_anuncio | A venda fechava no CRM e o anúncio nunca ficava sabendo. Desde a 0164 o ctwa_clid era estampado em contacts.source_metadata pelo primeiro toque — e o dado era SÓ DE ESCRITA: nada o lia de volta, então quem paga tráfego sabia de onde o lead veio e não conseguia dizer à plataforma quais leads viraram dinheiro. Duas tabelas. ad_platform_connections (org-escopada, única por (organization_id, platform)) guarda dataset e token cifrado por fn_encrypt_oauth — as MESMAS RPCs de calendar_connections e lib/webhooks/secrets.ts, sem terceiro caminho de cifra. Escopo de ORGANIZAÇÃO e não singleton de instalação como platform_google_oauth (0201): o dataset pertence à conta de anúncios do negócio, e uma agência com dois clientes na mesma VPS reportaria a venda de um na conta do outro. E o eixo é INDEPENDENTE do canal de mensagem — guardar em channel_sessions amarraria "reportar venda" a "ter canal oficial", quebrando quem recebe clique-para-WhatsApp em outro transporte. ad_conversion_dispatches faz três trabalhos numa tabela só, de propósito: idempotência (índice único (organization_id, lead_id, event_name) — contar a mesma venda duas vezes envenena o otimizador e não tem sintoma), superfície de falha para a tela /app/settings/conversoes (invariante 6 da doutrina de restrição de canal, lição da #144), e prova do que foi enviado. AS DUAS com RLS ligada e ZERO policies, grants revogados de anon/authenticated — mesmo desenho de platform_google_oauth, porque o token escreve na conta de anúncios do cliente e vazá-lo deixa terceiro injetar conversão falsa. Nenhuma função nova em public ⇒ item 9 da doutrina não é acionado. |
| 20260904052000 | 0214_leitura_de_anuncios | Quem paga a mídia não via a mídia. A 0213 fechou o caminho de VOLTA (a venda vira conversão na plataforma); o de IDA continuou fora do produto — para saber quanto custou o lead que acabou de atender, o dono do tráfego abria o Gerenciador numa aba separada. ad_insights_connections (org-escopada, única por (organization_id, platform)) guarda o token de LEITURA cifrado por fn_encrypt_oauth, a mesma cifra de calendar_connections, channel_sessions e da 0213 — sem terceiro caminho de cifra. Tabela NOVA e não coluna em ad_platform_connections, por quatro razões das quais a primeira já decide: (1) o índice único da 0213 é (organization_id, platform) e os dois tokens têm ESCOPOS DIFERENTES na plataforma (escrita de conversões vs. ads_read); (2) enabled é um interruptor só, e dividir a linha faria pausar o envio de conversões apagar o dashboard e vice-versa; (3) dataset_id/test_event_code são vocabulário de conversão e ficariam nulos para sempre; (4) raio de explosão — lerCredencial() alimenta o worker de lead.won, e um upsert desta feature na linha compartilhada derrubaria o reporte de vendas de quem nunca abriu a tela nova. Sem enabled de propósito: nada roda sozinho aqui (a leitura só acontece quando alguém clica em Atualizar), então um estado "conectado mas desligado" teria a mesma consequência visível de não estar conectado — desconectar é apagar a linha. RLS ligada com ZERO policies e grants revogados de anon/authenticated, mesmo desenho da 0213 e de platform_google_oauth: um token ads_read lê menos que o de conversões, e ainda assim expõe orçamento, criativo e performance de quem anuncia. Nenhuma função nova em public ⇒ item 9 da doutrina não é acionado. |
| 20260903230000 | 0211_contact_custom_fields | O contato ganha campos personalizados — e a anonimização passa a alcançá-los. A definição continua declarativa em crm_pipelines.settings.fields[], a mesma fonte que crm_leads.custom_fields já lê; o que entra aqui é só o VALOR, em contacts.custom_fields jsonb not null default '{}' com CHECK de jsonb_typeof = 'object' (dados corrigidos ANTES da constraint, porque o update.sh roda sem ON_ERROR_STOP e engoliria o 23514, deixando a coluna sem validação em silêncio). A segunda metade é a que não podia faltar: campo livre num registro de pessoa física recebe CPF — é o primeiro uso que um operador de clínica dá a um campo chamado "documento" —, e uma coluna de PII fora da anonimização faz o sistema responder "anonimizado" a um pedido do titular com o dado dele intacto, com o SLA de D+15 marcado como cumprido. A limpeza é TRIGGER NO ESTADO, não linha dentro do cascade, pela mesma razão já registrada em trg_contacts_anonimizado_limpa_propostas: há mais de um caminho que anonimiza — fn_lgpd_cascade_redact_contact e app/api/v1/lgpd/anonymize/route.ts:104, que faz UPDATE próprio e nem limpa consent/tags/source_metadata —, então pendurar num deles deixaria o outro vazando. BEFORE UPDATE OF is_anonymized porque o alvo é coluna da própria linha: em after seria preciso um segundo UPDATE, com a recursão que ele traz. Os dois revokes do item 9 do CLAUDE.md, e o bloco entra no baseline ANTES da varredura de anon (ele cria função). Aditiva e idempotente. Achado de @prevprocesso-maker no PR #465; a metade da LGPD foi acrescentada aqui. |
| 20260903000000 | 0206_contact_ai_authorization | A IA respondia allow by default: publicou agente para a sessão de WhatsApp, ela atendia TODO mundo. (Renumerada duas vezes na fila da main: 0202 → 0203 → 0206, porque 0203_comando_da_conversa, 0204_catalogo_de_produtos_da_loja e 0205_um_agente_indexa_mais_de_um_material entraram antes.) Não há gate de elegibilidade — lib/channels/pos-entrada.ts emite ai_agent.dispatch_requested para todo inbound novo, lib/agent-engine/edge/crm/drain.ts só checa se existe agente publicado para a sessão, e resolve-turn-agent.ts sempre resolve alguém ("silêncio não é desfecho possível", regra 5). Num número que é também o WhatsApp pessoal/comercial do dono, a IA assumia conversa de cliente atual, fornecedor, contato pessoal e conversa antiga. Correção: gate OPT-IN por canal (channel_sessions.metadata.ai_gate = 'allowlist'; ausente/'open' = comportamento de hoje, zero self-hoster afetado). Com o gate, a IA só responde quando o CONTATO está autorizado — ai_authorized_at (NULL = não autorizado) + ai_authorized_reason (respondi:<form>:<submission> | campanha:<id> | automacao:<rule> | retomada_manual). Contact-level como force_human, a trava oposta. Janela de validade (AI_ALLOWLIST_TTL_DAYS, default 21) impede submissão antiga reativar a IA; o turno autorizado renova o carimbo enquanto a conversa está viva. Aditiva e idempotente: colunas anuláveis, sem default, sem constraint — nenhuma linha existente viola nada, update.sh de clone não quebra. Anti-backlog no mesmo PR (sem schema): o drain pula evento cuja mensagem-gatilho já foi superada por inbound mais recente — vale para toda instalação, conserta "reiniciar o worker dispara histórico". |
| 20260903210000 | 0207_credenciais_de_ia_voltam_a_ser_lidas_por_manager | Membro que não é admin via a lista de credenciais de IA VAZIA — e concluía que não havia nenhuma cadastrada. A 0150 removeu a policy de SELECT por tenancy e deixou tenant_isolation_ai_provider_credentials_write como ÚNICA policy da tabela. Em Postgres, FOR ALL cobre também a leitura: a única regra aplicável ao SELECT passou a exigir fn_role_at_least(organization_id, 'admin'). A view ai_provider_credentials_safe é security_invoker = true de propósito — para que a RLS da base valha para quem consulta —, então um manager ou viewer passa na autorização da aplicação e é filtrado para ZERO LINHAS na base. A tela /app/ai/credentials não é admin-gated (ela só calcula canWrite para admin) e responde 200 com []: sem erro, sem aviso, e a pessoa conclui que a organização não tem credencial. Restaura o PAR que o cabeçalho da própria 0150 promete — escrita de admin, leitura por tenancy. Reabrir o SELECT não reabre o segredo: quem esconde api_key_encrypted/iv/tag é o GRANT POR COLUNA da 0150, não a RLS; há controle positivo em tests/invariants/ medindo que o manager lê a view segura e continua SEM alcançar as três colunas do segredo. O drop policy if exists antes do create não é zelo: o apêndice é re-aplicado pelo update.sh sem ON_ERROR_STOP, e o dump cria policy de mesmo nome — sem o drop, o create falharia em silêncio num clone. |
| 20260905160000 | 0218_configuracao_pre_go_live_atomica | O canal ganha uma transição segura entre pré-go-live e atendimento aberto sem regravar o jsonb inteiro. fn_configurar_pre_go_live_canal altera, numa única instrução e com filtro explícito de organização/canal ativo, somente ai_gate, ai_gate_mode e ai_test_phone_numbers; assim uma gravação concorrente de metadata do transporte não é apagada. O pré-go-live continua sendo o gate allowlist, mas o marcador separa a lista de testadores das autorizações temporárias de campanha/automação. A lista aceita apenas E.164; a RPC é invoker, exclusiva de service_role, com revogação das duas origens de EXECUTE. Não há backfill: canais existentes preservam o comportamento atual, e somente canais novos criados pelo produto nascem fechados. |
| 20260904220000 | 0217_relatorio_de_atividades | crm_lead_activities só era legível por negócio e por contato — "o que a equipe fez esta semana" obrigava a abrir negócio por negócio. Acrescenta fn_activity_report(p_org, p_from, p_to, p_tz, p_limit): uma agregação da janela em total, por ator (a tripla actor_kind/performed_by_user_id/actor_agent_id), por tipo, série diária e as N linhas mais recentes com o negócio e o contato. A contagem roda no Postgres de propósito: trazer a janela inteira pela rede para contar em JavaScript é o que derruba uma VPS de 2 GB — é como o PR #418 fazia no deploy de origem, onde a base é de uma clínica só. SECURITY INVOKER como a 0037/0133: o escopo é a policy crm_lead_activities_select (0042), que já herda a visibilidade do negócio-pai por fn_can_view_lead — agent em modo 'own' colapsa às próprias atividades, viewer/manager/admin veem a org. Promover a DEFINER vazaria entre inquilinos, e tests/invariants/relatorio-de-atividades.test.ts reprova quem tentar. O fuso é parâmetro: agrupar o dia em UTC joga a atividade das 21h de Brasília no dia seguinte, e o relatório afirmaria trabalho num dia sem trabalho. Índice novo idx_lead_activities_org_perf porque os três existentes lideram por org mas seguem com contact_id/lead_id/type — nenhum serve a um recorte de período org-wide. Extraído da contribuição de @clinicacentrodosorrisosc-code (PR #418). |
| 20260905120000 | 0231_organizacao_e_acesso_atomicos | Organização + admin atômicos e idempotentes; aceite serializado não restaura privilégio em replay. |
| 20260905150000 | 0219_recibo_de_criacao_confiavel | Recibo de criação como autoridade exclusiva do servidor; marker de procedência, RLS restritiva e bloqueio de TRUNCATE sem afetar LGPD/MCP. |
| 20260904011000 | 0209_mover_em_lote_sem_colidir_posicao | Mover N cards de uma vez pelo funil gravava a MESMA position_in_stage nos N. BulkActionBar mandava um escalar fixo (1_000_000) para o lote inteiro e o handler o aplicava com um update ... in (ids). position_in_stage é fractional indexing, e midpoint(prev, next) de lib/kanban/fractional-indexing.ts devolve NaN quando os dois vizinhos empatam — o próprio arquivo diz que o chamador deveria disparar um rebalance global, que não existe. Consequência em duas camadas: a ordem entre os cards do lote fica indefinida (o quadro se reordena a cada refetch) e o primeiro arrasto para ENTRE dois deles manda NaN como posição. fn_mover_leads_em_lote dá a cada card do lote uma posição DISTINTA — piso da etapa de destino + 1000 por card (o mesmo STEP do TypeScript), na ordem em que estavam no quadro — num update único. Função e não N updates no handler: pelo PostgREST, um valor por linha seria ou upsert (obrigando a devolver todas as colunas NOT NULL) ou até 50 idas ao banco, e um lote que falhasse na 17ª deixaria 16 movidos e 14 parados sem quem desfizesse; o update único torna o lote atômico. security INVOKER de propósito (a RLS de crm_leads é o piso, e nada aqui precisa vê-la de fora), e por isso a varredura de anon do fim do baseline — que só alcança prosecdef — não a cobre: revoke/grant explícitos das duas origens. Devolve a etapa de ORIGEM de cada card para o handler seguir emitindo uma atividade de timeline por lead movido. |
| 20260904000000 | 0208_moeda_da_organizacao | O produto inteiro presumia real, e a presunção não morava em lugar nenhum que alguém pudesse mudar. catalog_products.moeda (0204) nasce 'BRL' e — medido no fonte — ninguém manda outra coisa: o formulário de "Novo produto" não declara o campo no Rascunho nem o envia no doRascunho(), e lib/catalogo/planilha.ts não mapeia coluna de moeda. Só a ROTA aceita moeda no corpo, o que é pior que não aceitar — nenhuma tela oferece, então o único jeito de um produto não ser BRL é chamar a API por fora do produto. Uma loja no México cadastra em pesos, o banco guarda 'BRL', e o agente cota esse número ao cliente com o símbolo errado: o dado está certo e o rótulo mente. Coluna na organizations, e não settings jsonb: é a mesma classe de locale e timezone, que já são colunas de primeira classe da tabela; jsonb aqui seria o anti-pattern 6. Nome currency e não moeda: a doutrina manda _cents + currency ISO-4217 e duas das três tabelas com dinheiro já obedecem (crm_leads, orders) — catalog_products.moeda é o desvio, e renomear coluna já distribuída quebraria o update.sh de quem instalou, então ela fica e a nova não propaga o desvio. DIRC: não é duplicação sem dono (a org é a fonte na ESCRITA, e a linha guarda a moeda com que nasceu — o certo para orders, que são fatos históricos), nenhuma FK a traz, e não é calculável: é declaração de quem opera. O CHECK é de FORMA (^[A-Z]{3}$), não de vocabulário fechado, então fica FORA de vocabulario-banco-x-typescript — precedente idêntico e deliberado no irmão catalog_products_moeda_iso. Aditiva: o default satisfaz a constraint em toda linha existente; o update que vem antes dela existe para o clone onde a coluna tenha sido criada à mão. |
| 20260904190000 | 0215_juntar_contatos_duplicados | fn_mesclar_contatos — a mesma pessoa cadastrada duas vezes vira uma, sem perder histórico. contacts.is_merged_into existe desde a 0003 e é o que faz os três índices únicos parciais (telefone, e-mail, CPF) tolerarem o perdedor — mas o único produtor dela era a data migration de mão única da 0027. merge_queue está no schema desde a 0003 sem produtor, contact.merged está em lib/audit/actions.ts sem emissor, e components/contacts/MergeDialog.tsx dizia ao operador, na tela, "mesclar via SQL". Esta é a peça que faltava para os três. A lista de FKs a repontar sai do CATÁLOGO (pg_constraint), não de uma lista à mão: a doutrina de migrations manda conferir o catálogo, e uma lista escrita à mão envelhece em silêncio — a tabela criada amanhã apontando para contacts não entraria nela e o histórico ficaria pendurado no perdedor. O ponteiro POLIMÓRFICO (crm_lead_links.target_id com target_kind='contact') entra explicitamente porque catálogo nenhum o enxerga. O perdedor vira LÁPIDE, não delete: nenhuma FK fica órfã mesmo quando uma linha não pode ser repontada (colisão com uniq_job_queue_one_running_per_contact, estado efêmero de runtime — repontada linha a linha, com o que sobrou contado no retorno), e são os índices parciais que liberam telefone/e-mail para o vencedor herdar. Herda waha_lid porque wa_identity/wa_lid são GERADAS: sem isso fn_upsert_wa_contact (que filtra is_merged_into is null) criaria um contato novo na mensagem seguinte e refaria a duplicata que acabou de ser desfeita. CPF e consent NÃO são herdados — CPF é par preso por check e cifrado com a chave da instalação; consent é registro legal de quem autorizou, e herdá-lo fabricaria consentimento. Recusa contato anonimizado (L-04 não se desfaz pela porta dos fundos) e de outra org. Extraído do PR #418 de @clinicacentrodosorrisosc-code, que resolveu o mesmo problema no nível do CARD do funil; aqui a peça é o CONTATO, que é a mesma em todo nicho. |
| 20260904210000 | 0216_agent_inbox_items_resolved_at | O aviso "número calado" nunca se fechava sozinho, mesmo com o agente respondendo normalmente. lib/agent-engine/pacing/aviso-de-janela.ts (commit 4912c5230, 2026-08-30) resolve o aviso de janela de envio fechada com update agent_inbox_items set status = 'resolved', resolved_at = now(), mas agent_inbox_items nunca teve coluna resolved_at — nem no snapshot original, nem em migration nenhuma depois. O erro (column "resolved_at" ... does not exist) é engolido de propósito (fire-and-forget, para não derrubar a resposta ao lead), então o UPDATE falhava em SILÊNCIO a cada turno dentro da janela, e o aviso "as respostas da IA estão esperando a janela de envio abrir" ficava aberto na Central para sempre — dando a impressão de canal/loja fechada mesmo com tudo funcionando. Medido em produção: dezenas de falhas por hora, todo dia, desde que o código de resolução foi publicado. Correção aditiva e nullable: add column if not exists resolved_at timestamptz. |
Reproducibility
Migrations were applied directly via the Supabase MCP apply_migration tool during the autonomous bootstrap session. The SQL of each migration is also embedded in the corresponding spec under docs/specs/0X-spec-*.md and the database keeps them in supabase_migrations.schema_migrations.
To re-apply on a fresh Supabase project, replay the migrations in version order via supabase db push (Supabase CLI) or via the MCP.
Tables created (33 total, all RLS enabled)
- Platform: organizations, user_organizations, platform_admins, api_tokens, api_audit_log, user_recovery_codes, idempotency_keys
- Bus: event_log
- Customer 360: contacts, crm_pipelines, crm_stages, crm_leads, crm_lead_activities, crm_lead_links, merge_queue
- WhatsApp: channel_sessions, channel_session_warmup, conversations, messages, webhook_events_log
- AI: ai_agents, ai_knowledge_sources, ai_knowledge_versions, ai_chunks, ai_invocations, ai_pricing (global), ai_budgets, ai_faq_items
- Integrations: tenant_integrations, orders, nuvemshop_products
- Compliance: lgpd_requests
- Ops: incidents
| 20260905210000 | 0220_suporte_temporario_por_sessao | Sessão temporária vinculada ao Auth, papel efetivo e cercas readonly de DML/RPC/Storage; baseline idempotente. |
| 20260906010000 | 0221_interface_por_vinculo | Interface por membership, aceite assinado/replay preservado, criação responsável e publicação Realtime. |
| 20260906020000 | 0222_fronteira_do_atendimento | Atendimento com revisão, entrada serializada, encerramento explícito da demanda e provenance operacional. |
| 20260906030000 | 0223_origem_imutavel_do_evento | Recibo server-only da origem por evento, compartilhado entre consumers/retries; emissor autenticado recusa campos reservados de origem. |
| 20260906120000 | 0224_presenca_e_recuperacao | Presença humana com CAS e autoria, relógio de confirmação, recibo privado da recuperação, invalidação por inbound e proteção compartilhada. PG15; baseline INSTALL/UPDATE. |
| 20260907010000 | 0225_google_reconciliacao | Destino pessoal, reconciliação Google com revisão/claim/CAS, cursor cercado, redação de snapshots e elegibilidade derivada do titular e dono obrigatório na decisão. |
| 20260907020000 | 0226_meet_e_entrega_transacional | Conferência assíncrona na identidade/claim Google; entrega transacional com fronteira e aquisição original, retry humano, estado visível e minimização LGPD. |
| 20260907030000 | 0227_autonomia_e_respostas_revisadas | Pausa preserva publicação; modo assistido, revisões específicas de contexto, rascunho/feedback e envio aprovado cercado pela aquisição original; exportação e redação. |
| 20260907040000 | 0228_roteamento_por_canal_e_reservas | Responsáveis por canal, claim automático com capacidade global e retry durável; reserva WAHA com recibo privado, lease e retry da mesma identidade. Independente de 0227; baseline PG15. |
| 20260907050000 | 0229_mfa_e_lgpd_agenda | MFA nas quatro ações humanas, ordem de locks LGPD/agenda e footprint de avisos de presença/Meet na redação; baseline e backfill idempotentes. |
| 20260907060000 | 0230_reserva_pre_go_live | A reserva transacional de novos canais WAHA preserva o pré-go-live da plataforma; retry mantém política e identidade existentes. Forward-fix da integração, sem alterar 0228 aplicada. |
| 20260920001000 | 0347_modulo_voip | Módulo VoIP (SIP/Asterisk + IA de voz via OpenAI Realtime) — mantém só ai_agents.channel (coluna aditiva, default whatsapp, separa o eixo canal do eixo kind já existente) e phone_numbers+fn_resolve_inbound_number (roteamento de DID→org, com os dois revoke/grant do item 9 do CLAUDE.md). O crm_calls original desta migration foi removido: a main mergeou nesse meio-tempo uma tabela de chamadas própria (voice_calls, WhatsApp/WaCalls, #628/#697) — ver 0337 abaixo, que unifica os dois em vez de manter duas tabelas de chamada que não conversam entre si. Arquivo RECOMPOSTO na renumeração (18/09): nasceu 0232_modulo_voip no PR #677 e foi apagado por acidente no commit 97eed0955; o bloco do apêndice do baseline tinha sobrevivido, e é dele que o arquivo foi refeito. |
| 20260920001100 | 0348_voice_calls_sip | Unifica crm_calls (módulo SIP; criada pela antiga 0232 deste PR, que a 0336 recomposta já não cria) dentro de voice_calls (WhatsApp/WaCalls, 0233-0236) — a triagem do PR #677 já tinha apontado que duas tabelas de chamada que não conversam é o primeiro ponto a resolver na integração; isto adianta essa resolução. channel_session_id/wacalls_call_id viram nullable (SIP não tem sessão pareada nem id do binário WaCalls); provider discrimina wacalls/sip; colunas aditivas asterisk_channel_id/lead_id/ai_agent_id/handled_by/transcript/metadata, todas nullable/default seguro — nenhuma linha de WhatsApp existente é afetada. Vocabulário de status (starting/ringing/connected/ended) é do binário WaCalls upstream, preservado sem alteração — o fluxo SIP é mapeado nele (ringing→ringing, in_progress→connected, terminal→ended+end_reason livre) em vez de estender o CHECK. Migra as linhas existentes de crm_calls e derruba a tabela antiga no mesmo arquivo (nunca mergeada em main, sem histórico externo a preservar). fn_lgpd_cascade_redact_contact ganha transcript = null na redação de voice_calls — sem isso, anonimizar um contato deixaria a transcrição da ligação (que pode conter o nome dele, falado em voz) intacta. Aditiva e idempotente. Na renumeração (18/09), a fn_lgpd_cascade_redact_contact foi refeita sobre a definição VIGENTE da main (só acrescenta transcript = null): redefinir a partir da cópia de quando o PR nasceu desfaria em silêncio o que a main consertou na cascata depois. |
| 20260920001200 | 0349_voip_trunk_settings | Tela de configuração de trunk SIP por organização (Configurações > Trunk SIP) — hoje o trunk é fixo em asterisk/pjsip.conf, fora do banco. voip_trunk_settings (chave primária organization_id, um trunk por org) guarda host/porta/usuário e senha cifrada AES-256-GCM (mesmo esquema de ai_provider_credentials, lib/crypto/aes_gcm.ts) + endpoint_name derivado (org-<uuid>-trunk-endpoint, nunca digitado, pra nunca divergir do bloco em pjsip.conf). View voip_trunk_settings_safe (security_invoker=true) nunca expõe a senha cifrada. RLS: leitura por qualquer membro da org, escrita só admin. Aplicar no Asterisk continua MANUAL nesta fase (decisão explícita) — a tela mostra o bloco pronto pra colar logo após salvar (única janela em que a senha em claro existe, no form do navegador — nunca reconstituível depois). app/api/v1/calls/route.ts passa a resolver o endpoint_name daqui, com fallback pra VOIP_TRUNK_ENDPOINT (env) enquanto uma org não migrou pra tela. |
| 20260914230000 | 0253_politica_de_cadastro | platform_settings (singleton id=1) guarda se a instalação aceita cadastro aberto ou só por convite. Default aberto — preserva o comportamento anterior. RLS ligada com zero policies; só service_role lê e escreve. Baseline PG15, idempotente. |
| 20260907120000 | 0233_chamada_de_voz_wacalls | Schema pro canal de chamada de voz (WaCalls) — spec docs/specs/18-spec-voice-calls-wacalls.md. channel_sessions ganha o provider 'wacalls' (mesmo padrão de 'zernio'/'meta_cloud': colunas nullable específicas de provider na mesma tabela — wacalls_session_id, wacalls_jid, wacalls_paired_at — em vez de tabela por provider), com a mesma dupla de CHECK que os outros três já têm (vocabulário fechado + coluna de identidade obrigatória por provider). Tabela nova voice_calls (RLS via fn_user_org_ids() desde já, updated_at por fn_set_updated_at — o padrão de 27 tabelas contra as 5 de fn_touch_updated_at). Não guarda recording_url: gravação de chamada fica deliberadamente fora desta versão (decisão de produto §1.2 da spec — grava voz é dado sensível LGPD, sem consentimento nem entrada no cascade de redact ainda). Provado 3× contra o projeto Supabase Cloud de teste: install limpo + 2 reaplicações idempotentes, todas exit 0. |
| 20260907130000 | 0234_voice_calls_no_realtime | Forward-fix da 0233: voice_calls nunca entrou na publicação supabase_realtime. Achado testando ao vivo, não em revisão de código — uma ligação de verdade chegou no número pareado, o worker gravou status='ringing' certinho em voice_calls (log confere), e o frontend (hooks/voice/useVoiceCallSession.ts, que assina a tabela via useRealtimeChannel) ficou mudo: IncomingCallBanner nunca apareceu, a chamada tocou 46s e encerrou sem resposta com a tela quieta o tempo todo. select from pg_publication_tables confirmou a tabela ausente da publicação antes do fix e presente depois. Mesmo padrão idempotente das migrations 0025/0071/0078/0082/0183. |
| 20260908140000 | 0235_voz_isolamento_lgpd_e_dono | Forward-fix da 0233 em seis blocos, todos idempotentes. (1) A policy tenant_isolation_voice_calls_all nasceu sem o for all explícito e sem revoke all ... from anon: o comportamento casava com o nome por default do Postgres, não por declaração — recriada na forma que 0067/0050/0068/0085 fixaram. (2) voice_calls guarda peer_phone, telefone de gente, e estava FORA de fn_lgpd_cascade_redact_contact: depois de anonimizar um contato o número real sobrevivia ligado ao contact_id, um caminho de reidentificação — mesmo argumento que a foto de perfil já tinha em lib/lgpd/redact-cascade.ts. Entra como passo 7b, preservando direção, status, motivo e tempos (o registro de "houve uma chamada de 12 minutos" não identifica ninguém e sustenta métrica e fatura). (3) Coluna nova owner_user_id — quem esteve NA LINHA —, com índice parcial (organization_id, owner_user_id, answered_at) where answered_at is not null: sem ela qualquer colega da organização desligava a ligação de qualquer outro e a linha do tempo dizia "Sistema". (4) fn_update_last_activity_at ganha 'voice_call' na lista positiva da 0079, e só ele: voice_call_missed fica de fora de propósito, porque telefone que tocou sem resposta é constatação de silêncio, não quebra dele. (5) fn_attendant_metrics ganha a CTE voice_agg e os campos calls_answered/call_seconds: quem passa o dia ao telefone tinha produtividade zero. (6) A FK voice_calls.channel_session_id vira on delete restrict — apagar o canal deixa de apagar o histórico de ligações em cascata silenciosa e passa a exigir arquivamento, a mesma garantia que conversations/messages já davam. |
| 20260909190000 | 0232_nome_de_sessao_waha_cabe_no_teto_do_waha | fn_reserve_channel_connection gerava waha_session_name de 69 chars (org_<32>_<32>); o WAHA latest-2026.7.2 valida name com @MaxLength(54) e todo POST /api/sessions de canal novo tomava 400 (waha_create_400). Prefixo da org encurta para 8 (org_<8>_<32> = 45), alinhado com a busca de canal de onboarding no mesmo corpo. Repara canais WAHA nunca pareados com nome fora do teto. Forward-fix da 0230. |
| 20260911170000 | 0238_convites_de_time_persistidos | team_invites: o convite pendente passa a existir no banco (antes era só token stateless + linha no aceite). Habilita a lista de convites na tela de Equipe, o aviso de e-mail não despachado e a REVOGAÇÃO de convite (o id da linha = invite_id do token; o aceite recusa convite revogado). Status é derivado. RLS team_invites_select (manager+) / team_invites_write (admin). Baseline idempotente com dedup antes do índice único. |
| 20260911120000 | 0236_opt_in_de_chamada_de_voz | A chamada de voz nasce DESLIGADA por organização (org_voice_calls), com quem aceitou o risco e quando. Ausência de linha é "desligado" — aplicar não liga nada para ninguém. Leitura org-flat, escrita de admin no banco. Baseline INSTALL/UPDATE idempotente. |
| 20260920002000 | 0350_catalogo_financeiro | Primeira camada do módulo de comanda/financeiro: financial_accounts (onde o dinheiro fica), payment_methods (como o cliente paga — e cada forma APONTA para a conta em que aquele dinheiro cai) e account_plans (classificação, com direction in/out). A ordem é obrigatória: a forma de pagamento decide em qual conta a entrada é lançada quando a comanda é finalizada, então sem esta camada a comanda não tem onde depositar. Nenhum saldo é gravado — opening_balance_cents é o ponto de partida declarado, e o saldo corrente é sempre derivado por soma (saldo gravado e lançamentos divergem no primeiro estorno, sem dar sinal). Dinheiro em _cents + currency. payment_methods.account_id é on delete restrict de propósito: cascata deixaria formas apontando para o nada e lançamentos futuros sem destino, em silêncio. RLS tenant-aware com escrita de manager+ (quem atende não define plano de contas). Nada disso existia no destino. |
| 20260920002100 | 0351_comanda_e_financeiro | A comanda e o que ela move: sales, sale_items, commission_rules, commissions, financial_entries, loyalty_ledger — mais fn_finalizar_comanda e fn_estornar_comanda. A finalização faz seis coisas numa transação (venda, comissão por item, entrada na conta que a forma de pagamento determina, ponto de fidelidade, conclusão do agendamento), sob FOR UPDATE — sem o lock, duas finalizações simultâneas geravam lançamento em dobro. Invariantes no schema, não na prosa: nada é apagado (cancela/inativa); nenhum saldo é coluna (nem de conta, nem de fidelidade — é sempre soma); estorno é contra-lançamento com reverses_entry_id, nunca exclusão; comissão é resolvida na INCLUSÃO do item e congelada na linha, e a finalização não recalcula; numeração por organização não reinicia; e um trigger recusa UPDATE em lançamento já pago (valor/conta/direção/data). O CHECK sales_finalizada_tem_forma põe no schema a regra de que finalizar exige forma de pagamento. A conclusão do agendamento tem a guarda status not in ('cancelled','no_show') que o sistema de origem não tinha em todos os caminhos. |
| 20260920002200 | 0352_comanda_por_agendamento_e_unica | Índice único parcial sales (organization_id, appointment_id) where appointment_id is not null and status <> 'cancelled'. A rota consulta antes de abrir, o que resolve o toque repetido mas não a corrida — e duas comandas para o mesmo atendimento não dão erro nenhum: são faturadas separadamente e o cliente paga duas vezes. Parcial nas duas pontas (avulsa é a maioria; cancelada deixa de valer, senão cancelar por engano trancaria o agendamento para sempre). Dedup antes do índice, mantendo a comanda mais antiga. |
| 20260920002300 | 0353_relatorio_financeiro | fn_relatorio_financeiro(org, de, ate): entradas, saídas, saldo, comandas finalizadas e estornadas, faturado, ticket médio, por forma de pagamento e por profissional. Agrega NO BANCO porque o PostgREST corta em 1000 linhas sem avisar — nesta mesma base a primeira medição do saldo devolveu R$ 141.436,00 em vez de R$ 641.103,60, e um relatório que erra assim é pior que um que não existe. stable e INVOKER (não definer): a RLS de cada tabela continua valendo, em vez de o isolamento ser reimplementado no corpo. Só lançamento paid entra; o estorno se desconta sozinho por ser lançamento de direção contrária. Revogada de public, anon. |
| 20260920002400 | 0354_regra_de_comissao_inativa | commission_rules.is_active + índice parcial nas ativas. Sem isto a regra não podia entrar no catálogo financeiro genérico (que inativa em vez de apagar), e NÃO HAVIA porta nenhuma para cadastrá-la: toda comissão nascia 0% em toda instalação, e a precedência escrita na 0240 nunca era exercida. Inativar é o certo, não só conveniente — o percentual aplicado já está congelado no item, e o que se perderia ao apagar é a resposta a "por que aquela comanda saiu com este percentual". |
| 20260920002500 | 0355_saldo_de_fidelidade | fn_saldo_de_fidelidade(org, contato): sum(points) do livro-razão, stable e invoker. O saldo nunca é coluna (invariante 2 do módulo), e o que faltava era alguém poder fazer a soma. No banco pelo mesmo motivo da 0244 — o PostgREST corta em 1000 linhas sem avisar, e aqui o truncamento é pior que num relatório: vira prêmio negado a quem tinha direito, no balcão. Por CLIENTE, nunca agregado: o total geral esconde erros que se compensam, como a validação desta migração registrou. Revogada de public, anon. |
| 20260920002600 | 0356_relatorio_por_servico_e_cliente | fn_relatorio_financeiro ganha por_servico e por_cliente (top 10 cada) sem mudar a assinatura. São as duas perguntas que decidem o que o negócio faz na semana seguinte, e faltavam. O serviço é agrupado pela DESCRIÇÃO congelada no item, não pelo nome atual do tipo — é o único agrupamento que continua verdadeiro depois de alguém renomear um serviço, e o item avulso entra em vez de sumir. O limit 10 está nas LISTAS, nunca nos totais: resumir e truncar são coisas diferentes. |
| 20260920002700 | 0357_lancamento_recorrente | recurring_entries (o molde) + financial_entries.recurring_entry_id + índice único parcial por (molde, competência). Aluguel, internet e salário não tinham conceito no destino, e a saída era lançar à mão todo mês — a que se esquece em fevereiro e faz o relatório parecer melhor do que foi. O molde NÃO movimenta dinheiro: quem nasce é uma linha PENDENTE, porque o sistema sabe que a conta vence e não sabe se alguém pagou. Mudar o molde não reescreve o que já foi gerado. A idempotência é do índice, não de uma flag de "último gerado" — esta falharia em duas execuções simultâneas. day_of_month 1 a 31, e o que não existe no mês cai no último dia dele. RLS de escrita em manager: definir o aluguel é política. |
| 20260920002800 | 0358_preco_do_tipo_de_evento | calendar_event_types.default_price_cents. O catálogo de serviços já é o de tipos de agendamento (decisão da 0240) e faltava o preço: no balcão o valor era digitado a cada item — onde o erro de dinheiro entra — e o faturamento em lote era impossível, porque abrir a comanda de um atendimento passado exige saber quanto ele custa. NULLABLE e sem default de propósito: nem todo negócio tem preço fixo, e vazio significa "digite na hora", o comportamento de antes. É SEMENTE, nunca preço final — o item continua congelando o seu unit_price_cents na inclusão. |
| 20260914220000 | 0252_aniversario_do_contato | contacts.birthday_md (coluna gerada, mês e dia num inteiro: 914 = 14/09) mais índice parcial por organização. birthdate existia desde cedo e não acionava nada — dado que entra e não sai por lugar nenhum. A coluna existe para o cron contact-birthdays buscar por igualdade em vez de varrer a tabela; extract sobre date é immutable, que é o que a coluna gerada exige, e to_char não é. |
| 20260912200000 | 0239_registro_nao_fica_pendente | Tipo de evento que ninguém consome não é fila: event_log.status nasce pending (DEFAULT) e nenhum drain seleciona tipo sem handler (drain.ts filtra event_type in (handlers); o do agent-engine filtra ai_agent.dispatch_requested), então o registro acaba a vida pending — issue #753: 626 linhas, 8 tipos, consumed_by vazio. fn_event_log_e_registro (lista dos tipos que são REGISTRO, em um lugar só) + trigger trg_event_log_marca_registro BEFORE INSERT (pending → done no nascimento) + backfill do estoque. Não toca em *_requested (comando sem consumidor PRECISA ficar visível — #129) nem em tipo consumido (nasceria done e o handler nunca rodaria); consumed_by fica vazio de propósito. Catraca: tests/unit/evento-de-fato-nao-fica-pendente.test.ts. |
| 20260912010000 | 0237_criador_provisorio_sai_na_entrega | Quem cria um tenant PARA OUTRA PESSOA sai dele quando essa pessoa assume. SEGUNDA TENTATIVA — a primeira deduzia a entrega de "existe outro admin aceito", o que é falso numa organização que a pessoa abriu para si e depois deu admin a um sócio: em produção o dono do servidor perdeu o vínculo com a PRÓPRIA empresa e a tela respondeu "Você não tem nenhuma organização ativa". A causa era de INFORMAÇÃO, não de predicado. fn_create_tenant_with_owner JÁ compara owner_email com o e-mail de quem cria (inline, para escolher interface_settings) e não anotava — agora anota em user_organizations.provisional_until_handover, e só quando o tenant é de outra pessoa. Organização criada por /signup nunca passa por ali e nasce false. fn_accept_team_invite apaga, no aceite de um admin, apenas os vínculos MARCADOS — depois de gravar o do dono, nunca antes. Sem expurgo retroativo, de propósito: vínculo antigo não tem a marca e deduzi-la é o erro que custou o acesso de alguém; casos anteriores resolvem à mão. |
| 20260912220000 | 0240_credencial_de_enfeite_nao_derruba_a_leitura | public.fn_decrypt_oauth era return pgp_sym_decrypt(ciphertext, k) — decifrava QUALQUER bytea. Como as colunas cifradas são NOT NULL, "ainda não configurado" é gravado como byte de enfeite (Buffer.from([0]), o que lib/waha/webhook-auth.ts já documentava), e toda leitura que caía nesse registro estourava 39000 Wrong key or corrupt data — issue #754: 10 a 30% das chamadas do RPC em 500, continuamente, nos mesmos registros. A guarda de forma é medida, não escolhida: pacote menor que 66 bytes (o menor que fn_encrypt_oauth produz, texto vazio) ou sem o bit 7 do primeiro byte (pacote PGP real medido: 0xC3) devolve null — o contrato que os leitores já tratam — e a checagem de tamanho vem antes do get_byte() porque este estoura em bytea vazio. Cifra de verdade com a chave trocada CONTINUA levantando: é misconfiguração e tem de aparecer. Não reescreve linha nenhuma e não reabre ACL (segue só service_role). Catraca: tests/unit/credencial-de-enfeite-nao-derruba-a-leitura.test.ts + invariante com Postgres de verdade. |
| 20260914150000 | 0241_rascunho_de_agente_sem_numero | ai_agent_versions.channel_session_id deixa de ser NOT NULL: o rascunho do agente existe antes de haver WhatsApp pareado (instalação nova não tem canal, e o editor exigia o número para salvar). Publicar sem número continua recusado por fn_publish_ai_agent_version. Sem backfill. |
| 20260914160000 | 0242_aviso_de_caso_parado | Kind novo em agent_inbox_items: case_stale — o caso em awaiting_human que ninguém abriu há 24h volta a pedir passagem na Central (ref_kind='agent_case'). Nada avisava que um caso estava parado, e o cliente espera do outro lado: medido num CRM em produção com o mesmo desenho de fila, 22 pedidos esquecidos, o mais antigo há 17,6 dias, numa fila que era usada (72 de 102 resolvidos). O CHECK é recriado INTEIRO (drop if exists + add) porque add constraint não é idempotente e não há alter constraint ... add value para CHECK — mesmo formato da 0233. Índice parcial (organization_id, ref_id) where kind='case_stale' and status='open', que é a única pergunta que o watcher faz. Sem backfill: nenhuma linha usa o valor novo. Habilita o cron case-stale-watcher, que também dá a agent_cases.followup_attempts o escritor que ela nunca teve. |
| 20260914170000 | 0243_chamada_de_api_tem_prazo_de_trava | alter role authenticator/authenticated set lock_timeout='4s'. No Postgres lock_timeout é 0 por padrão — esperar para sempre, e o cliente HTTP do produto desiste em 10s (DEFAULT_TIMEOUT_MS). Juntas, as duas coisas produzem um defeito que nenhuma tem sozinha: aos 10s o navegador mostra "Erro inesperado" (sem identificador — o erro é DELE, não do servidor), a consulta continua viva segurando a fila, o botão volta a aceitar clique, e o seguinte empilha atrás. Medido em produção em 2026-09-12 pelo "Enviar link ao cliente" (fn_meet_action): dez chamadas simultâneas, Postgres a 357% de CPU com load 7,33, app e worker em 0,16%; cancelá-las devolveu o banco a 1,47%. No PAPEL e não na função porque a varredura que veio junto (tests/invariants/chamada-de-api-tem-prazo-de-trava.test.ts) achou sete irmãs com a mesma forma — fn_reply_action, fn_mesclar_contatos, fn_reserve_channel_connection, fn_set_channel_routing, fn_lgpd_anonymize_contact, fn_google_resolve, fn_google_selection — e consertar uma a uma deixaria toda função futura nascer com o defeito. service_role fica de fora: trabalho de fundo pode esperar. 4s é abaixo dos 10s do cliente (para a recusa chegar à tela) e muito acima de uma operação legítima. Estouro levanta 55P03, traduzido pelas rotas. |
| 20260914180000 | 0244_tags_de_conversa_em_uso | fn_tags_de_conversa_em_uso(p_org): o seletor de etiqueta do Inbox passa a oferecer as etiquetas em uso nas conversas, unidas ao vocabulário canônico. Medido numa instalação real: o seletor oferecia 8 etiquetas de semente, nenhuma conversa tinha etiqueta, filtrar por qualquer uma devolvia zero, e a etiqueta que a lista exibia não estava entre as 8. security INVOKER, e isso é o ponto: a função recebe a organização por argumento e é concedida a authenticated, então definer aqui seria leitura cross-tenant — a classe de defeito que a 0149 consertou em emit_event/retrieve_top_k_chunks, e que o comentário de fn_gasto_de_ia_do_mes descreve por extenso. Sob invoker quem isola é a RLS de conversations. Como isso tira a função do alcance da varredura de definer, o gate dela é tests/invariants/tags-em-uso-aparecem-no-filtro.test.ts, com isolamento entre duas organizações e recusa ao anon. Sem coluna, sem constraint, sem backfill; reaproveita idx_conversations_tags_gin. Baseline idempotente (create or replace + revoke/grant). |
| 20260914195200 | 0245_followup_stale_nao_e_retry | followup_stale deixa de ser SQLSTATE 40001 (serialization_failure). Esse código pede RETRY da transação; o patch de follow-up usava a mesma mensagem para revisão velha e enrollment sumido — estados permanentes. Medido: 6000 erros/min, CPU 100%, mais de 24h, convoy de advisory lock num único contato. Passa a P0001. Catraca: tests/unit/followup-stale-nao-e-retry.test.ts. Baseline: as duas funções (fn_followup_revision, fn_followup_patch) no corpo canônico. |
| 20260912210000 | 0247_indices_fks_mensagens_e_runs | Índices parciais em foreign keys de messages e ai_agent_runs. Previne sequential scans inteiros na tabela filha durante deleções e cascateamentos em contatos, sessões de canal e mensagens (especialmente expurgo LGPD). Índices parciais idempotentes idx_messages_contact_id, idx_messages_channel_session_id, idx_ai_agent_runs_contact_id, idx_ai_agent_runs_channel_session_id, idx_ai_agent_runs_conversation_id, idx_ai_agent_runs_inbound_message_id, idx_ai_agent_runs_outbound_message_id. |
| 20260914090000 | 0248_caso_tem_assunto | agent_cases.kind — do que o caso trata, para triar a fila antes de ler. O assunto vivia só em texto livre (title/summary/blocker), o que basta com fila curta e não basta com volume: "quer marcar horário" e "está reclamando" pedem pessoas e urgências diferentes. Sem CHECK, pela doutrina de vocabulário ABERTO — o vocabulário útil muda com o nicho (clínica tem "remarcação", loja tem "troca"), e um CHECK fixo exigiria migration por nicho e quebraria o update.sh de clone com valor próprio; quem prende é TIPOS_DE_CASO no TypeScript, e a coluna fica fora do invariante vocabulario-banco-x-typescript (que só cobre coluna com CHECK). not null default 'outro' dispensa backfill. Índice parcial (organization_id, kind) where status in ('awaiting_human','awaiting_lead'), porque é nos abertos que se tria. |
| 20260914190000 | 0249_agenda_prazo_de_expiracao_do_pendente | Terceiro prazo em organizations.settings.agenda: pending_expires_after_minutes — quanto tempo um pedido não confirmado segura o horário. Por que uma migration para um campo de jsonb: fn_agenda_settings não faz merge, ela ENUMERA as chaves aceitas e rejeita extras ((p_config - 'a' - 'b') <> '{}' levanta 22023); sem recriá-la a tela salvaria o campo e receberia "não foi possível alterar os prazos". O campo é opcional de propósito — toda organização instalada tem duas chaves, e exigir três quebraria o PATCH vindo de uma aba aberta antes da atualização; ausente = default do TypeScript (1440). Piso de 15 minutos no corpo, porque abaixo disso a expiração corre com quem está decidindo. Sem backfill: a ausência já é estado válido. Habilita o cron agenda-expira-pendentes. O corpo é DERIVADO da versão em vigor: a função ganhou o portão fn_session_mfa_proven() na 0229, e recriá-la a partir do corpo antigo o apagaria — no baseline isso vira remoção de proteção no update.sh de quem já rodava. |
| 20260914200000 | 0250_guarda_contra_replay_do_gateway | Hook pgrst.db_pre_request que responde 409 a requisição cujo sb-request-id (UUIDv7) tem >5 min. O gateway do Supabase reexecuta 5xx sem limite; fn_followup_patch com revisão obsoleta (40001 → HTTP 500 no PostgREST) foi reexecutada ~2.300×/s por dois dias, esgotou o pool, o schema cache não carregou e todo o app respondeu 503 PGRST002. 4xx não é reexecutado; o loop morre na hora. Só registra o hook onde o papel authenticator existe. |
| 20260914210000 | 0251_acesso_da_ia_volta_a_trilha_do_operador | Forward-fix da 0218 (issue #602): a RPC gravava o LITERAL 'pre_go_live' em ai_gate_mode em toda chamada — abrir o canal ao público limpava o ai_gate e deixava o marcador de teste para trás. Enquanto o canal ficava aberto era inerte (o discriminador é ai_gate); na volta virava falha fechada e silenciosa: o script CLI do allowlist POR ORIGEM (scripts/ativar-gate-elegibilidade-ia.ts), que escrevia só {ai_gate}, devolvia o canal ao pré-go-live com a lista de testadores antiga, e o preflight do próprio script prometia autorizado onde o motor executava fora_da_lista_de_teste. Agora ai_gate_mode recebe o modo REAL (p_modo, no vocabulário do contrato da tela: open/pre_go_live). create or replace, sem DDL novo e sem backfill — o estado antigo é inerte e sai na próxima gravação da tela ou do script. |
| 20260921030000 | 0368_redes_sociais_nativas | Redes sociais no Inbox, identidade separada e credencial cifrada exclusiva do servidor. |
| 20260915000000 | 0254_lembrete_em_degraus | O lembrete deixa de ser um só. calendar_event_types.reminder_extra_offsets_minutes integer[] guarda os degraus ADICIONAIS (o principal continua em reminder_minutes_before), e calendar_appointments.reminder_sent_offsets_minutes integer[] passa a ser a autoridade sobre o que já saiu — com reminder_sent_at como filtro, quem recebeu o aviso de um dia nunca voltaria para o de três horas. Faixa (15 min a 7 dias) e teto de 3 extras num CHECK via fn_degraus_de_lembrete_validos (immutable, porque CHECK não aceita subconsulta). Backfill marca o degrau principal onde já havia carimbo, senão a primeira varredura depois da atualização reenvia lembrete a todo compromisso já avisado. Aditivo: clone que atualiza recebe '{}' e se comporta como antes. |
| 20260915010000 | 0255_atualizar_nao_desliga_o_lembrete | Atualizar o CRM desligava os lembretes que o operador tinha ligado. A 0194 corrigiu o histórico junto com o default, e o raciocínio valia naquele dia ("zero leitores e zero disparador"); ela mesma avisou que "depois do disparador, isto seria apagar a escolha de um operador". O disparador nasceu (agenda-reminder) e o update.sh re-aplica o baseline inteiro — então toda atualização desligava tudo, sem erro, sem log, com a tela mostrando o controle desmarcado. A guarda é o column_default: pelo VALOR é impossível distinguir "ninguém escolheu" de "o operador ligou", mas o default só difere de false antes da primeira aplicação da 0194 naquele banco. Ler o default ANTES de gravá-lo. |
| 20260915020000 | 0256_lead_do_ingest_nao_duplica | fn_nascer_lead_da_conversa: cria o lead de entrada serializando por (organização, contato) com pg_advisory_xact_lock, e devolve NULL quando já existe aberto. O check-then-act de nascimento-do-lead.ts deixava mensagens simultâneas do mesmo contato virarem vários cards — medido em produção: três mensagens seguidas, três negócios às 17:07, mesmo funil e mesma etapa. NÃO é índice único de propósito: unique (org, contact) where status='open' resolveria a corrida e quebraria o caso legítimo de dois negócios abertos criados à mão. A função não decide funil, etapa nem título: recebe tudo pronto, para a política continuar em TypeScript. |
| 20260915120000 | 0257_app_da_meta_da_instalacao | A credencial do APP da Meta sai do .env e vira linha de INSTALAÇÃO (public.platform_meta_app): o App Secret que assina a entrega do webhook e o verify token que responde ao handshake. O .env só se edita por SSH em quem instalou, e o App da Meta é UM por instalação atendendo N WABAs de N organizações — a partir do 2º número não havia como configurar o app sem mexer no que já funcionava (issue #850). Mesmo objeto e mesmo desenho de platform_google_oauth (0201) e platform_branding (0155): singleton (check (id = 1)), RLS ligada, revoke de anon/authenticated, leitura e escrita só pelo service_role atrás do gate administrativo, e updated_by para auditoria (platform_meta_app.updated / .verify_token_rotated). O verify token passa a ser GERADO NO SERVIDOR (randomBytes, exibido uma única vez) e guardado com a cifra que o repo já usa para segredo de canal (fn_encrypt_oauth, AES-256 com a chave na GUC) — sem criptografia nova; verify_token_created_at guarda a rotação. O .env NÃO é apagado: ele é o PISO de rollback (o agent.sh reverte só a imagem, então código NOVO sobre banco sem a 0257 é o caminho que dói) e a rota de verificação lê o BANCO PRIMEIRO, caindo para o ambiente quando não há par completo — as duas fontes não se misturam, porque segredo de um lado com token do outro é um app que não existe e falha calada em 401. Sem migração de dados do .env para o banco, por decisão do mantenedor. Baseline INSTALL/UPDATE idempotente. Catracas: tests/unit/app-da-meta-credencial-do-banco.test.ts e tests/unit/webhook-meta-le-do-banco.test.ts. |
| 20260915140000 | 0258_audit_log_so_recebe_linha | revoke update, delete, truncate on api_audit_log from public, anon, authenticated, service_role. O "append-only é do schema" era falso no Supabase real. Todo projeto Supabase nasce com default ACL de TABELAS em public (anon=arwdDxt, authenticated=arwdDxt, service_role=arwdDxt), então api_audit_log nascia com UPDATE, DELETE e TRUNCATE para os três; o GRANT SELECT,INSERT,REFERENCES,TRIGGER,TRUNCATE enumerado do dump só acrescenta. Medido no Supabase local (pg 15.8): os três papéis com DELETE,INSERT,REFERENCES,SELECT,TRIGGER,TRUNCATE,UPDATE; num pg15 com o default ACL reproduzido, set local role service_role; delete from api_audit_log … → DELETE 1. Com a service key (que ignora RLS) uma linha escolhida da auditoria era apagada ou reescrita pela REST; anon/authenticated só não o faziam porque a RLS não tem policy de UPDATE/DELETE. A primeira versão desta migration (0258_audit_log_perde_o_truncate, nunca publicada em release) revogava só TRUNCATE e ficou verde porque o prelude do test:db reproduz o default ACL do Supabase para funções, não para tabelas. Não quebra ninguém: nenhum caminho do produto altera ou apaga linha desta tabela pelos papéis do PostgREST (o delete do scripts/seed-e2e-capacidades.ts, que falharia calado, saiu); o expurgo é fn_expurgar_auditoria_vencida (0167), security definer de dono postgres; as FKs on delete set null rodam como o dono da tabela. Vigiado por tests/invariants/audit-log-sob-o-default-acl-do-supabase.test.ts, que concede o default ACL do Supabase à tabela, reaplica o bloco extraído do baseline e sonda grants, privilégio efetivo, permission denied nos três comandos, INSERT/SELECT, expurgo e FKs. |
| 20260915150000 | 0259_indices_redundantes_saem | Três índices cujo trabalho já é feito por outro, integralmente. (1) ai_models_provider_model_unique (provider, model_id), criado pela 0127, contra a constraint ai_models_unique (provider, model_id) do schema original — mesmas colunas, mesma ordem, os dois UNIQUE: é o "índice duplicado em ai_models" que o advisor de desempenho apontou numa VPS de cliente. Fica a constraint (dá nome à violação, aparece em pg_constraint, não cai com um drop index por engano); o índice sai, dentro de um guard que confere que a constraint existe — num clone onde ela tenha sido removida à mão, o índice da 0127 é a ÚNICA coisa impedindo cadastro duplicado de modelo. (2) idx_crm_lead_links_lead (lead_id) contra uniq_crm_lead_links_lead_target_link (lead_id, target_kind, target_id, link_kind) e (3) calendar_connections_org_pessoa_idx (organization_id, user_id) contra calendar_connections_conta_key (organization_id, user_id, provider, account_email): um btree responde por qualquer PREFIXO das suas colunas, então o de quatro atende tudo que o de um ou dois atendia. Índice redundante custa em todo insert/update e ocupa disco. O planner não os ignorava: medido em pg17 (20 000 vínculos, 2 000 leads), a busca por lead_id usava o de uma coluna quando ele existia (216 kB, custo 4,36) e passa a usar o de quatro (1464 kB, custo 4,49) — segue servida por índice; troca-se índice menor na leitura por um índice a menos em toda escrita. Os três drops têm guard, e não só o 1: o update.sh roda sem ON_ERROR_STOP, e "o largo é declarado no mesmo baseline" não prova que ele existe num clone onde a criação falhou em silêncio. O baseline deixou de criá-los para derrubar no fim: saiu a linha do dump (idx_crm_lead_links_lead) e a do bloco do calendário, e o bloco da 0127 só cria o seu índice onde a constraint ai_models_unique falta. Antes, toda instalação e todo update.sh construía os três (CREATE INDEX não concorrente, que trava escrita enquanto constrói) e os jogava fora no apêndice. Estado final igual ao da cadeia (0127 cria, 0259 derruba). |
| 20260921030100 | 0369_prospeccao_nativa | Busca com orçamento, candidatos únicos por organização e campanhas graduais com pausa e histórico; acesso exclusivo pelo servidor autenticado. |
| 20260921030200 | 0370_prospeccao_anonimizacao | Cascata canônica apaga telefone e enriquecimento; tokens pseudônimos restritos ao servidor impedem reimportar a mesma origem ou telefone após anonimização. |
| 20260921030300 | 0371_prospeccao_conversa_salva | A conversa e a proposta do agente ficam na campanha, separadas da configuração ativa. Revisão monotônica impede respostas atrasadas e abas concorrentes de sobrescrever o resumo. Acesso permanece exclusivo pelo servidor autenticado; sem envio, roteamento ou backfill. |
| 20260915160000 | 0260_ocupacao_do_google_do_dono | A ocupação do Google Agenda do dono não depende de quem consulta (issue #879, PR #883). A coleta de horários chegava aos eventos do Google pelo embed calendar_connections!inner, e a RLS da conexão (dono ou manager+, porque guarda token OAuth) escondia a conexão de um agent: medido, a mesma agenda rendia 1 evento ao dono, 1 ao gerente e 0 ao atendente, e a grade e o encaixe ofereciam horário em cima de compromisso que existe. Duas security definer estáveis — fn_agenda_ocupacao_google_do_dono(p_org, p_owner, p_de, p_ate) e fn_agenda_conexoes_google_do_dono(p_org, p_owner) — atravessam SÓ essa RLS, conferem o pertencimento no corpo (fn_user_org_ids(), com auth.uid() is null para o service_role e fn_is_platform_admin() pela paridade com a policy), filtram o dono e devolvem ocupação (início, fim, transparência, situação do evento e da conexão) — nunca título, descrição ou participantes. Por que função e não o client admin do PR: lib/supabase/admin.ts proíbe service role em rota de usuário, e o admin ficava escondido numa coleta que recebe o client de fora. EXECUTE revogado das duas origens (public, anon). Medido por sabotagem: trocar a conferência de pertencimento por true reprova o caso cross-org da função de conexões e NÃO o da de ocupação — ali a view calendar_selected_external_events já filtra por fn_google_counts_for_conflicts, que confere o pertencimento de quem chama; a guarda do corpo é defesa em profundidade na de ocupação e a única guarda na de conexões. Sem coluna, sem constraint, sem backfill. Número 0260 porque 0258/0259 estão tomados pelo lote 9 em integração. Gate: tests/invariants/agenda-ocupacao-google-do-dono.test.ts. |
| 20260915170000 | 0261_titulo_do_evento_pessoal_sai_do_alcance | O título do compromisso pessoal da agenda do Google sai do alcance do membro. calendar_external_events é o espelho da agenda PESSOAL de quem atende, o papel authenticated tinha SELECT de TABELA nela, e a view calendar_selected_external_events era select e.* — com o title dentro. Medido num banco instalado do zero (baseline da v1.26.0), com o título inserido à mão: um membro de outro papel, inclusive Somente leitura, lia o title da linha do colega — select title from calendar_external_events … → "Terapia sigilosa" — tanto direto na tabela quanto pela view, e has_column_privilege('authenticated','calendar_external_events','title','SELECT') respondia true. Alcance real: o privilégio estava aberto em toda instalação; o nome, só em agendas sincronizadas antes da v1.17.0. Desde a 0225 o sincronizador grava title nulo, e a 0225 não anulou as linhas antigas: sobra o que a ressincronização ainda não regravou (o rebuild completo, a cada 24h, cobre de 1 dia atrás a 90 dias à frente; o passado espera fn_expurgar_espelho_da_agenda, por padrão 90 dias após ends_at; a agenda que o sincronizador não lê não é regravada, futuro inclusive — conexão que não está saudável, membro revogado e agenda fora do catálogo do Google (a reserva é recusada ou não sai, medido no invariante), e agenda desmarcada, que o cron adia sem ler (app/api/v1/cron/agenda-google-sync/route.ts); e, DENTRO da janela, o evento cancelado escapa do rebuild — o page final apaga o não visto com and status<>'cancelled' e a leitura completa do Google não devolve cancelados —, guardando o nome até o expurgo mesmo sendo futuro, medido no invariante com o controle de que o confirmado que sumiu é apagado). O conserto fecha esse resíduo e vale como defesa em profundidade contra um escritor futuro. O conserto é no privilégio; a policy de leitura segue da organização, e esta migration não a toca. O SELECT de tabela sai e volta COLUNA A COLUNA, sem title; revogar a coluna sem revogar a tabela não faria nada, porque o privilégio de tabela cobre todas as colunas — e ele vem do default ACL de TABELAS do Supabase (ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES, que o dump também reemite), não de um GRANT desta tabela, que o dump não tem. A view é recriada com drop + create, e não com create or replace: o Postgres recusa tirar coluna do meio, e com security_invoker um e.* já expandido faria TODA leitura de ocupação por membro falhar com permission denied no title. A lista explícita é o conserto de fundo — e.* era a forma de a próxima coluna nascer exposta. service_role fica intocado e mantém SELECT/UPDATE na coluna, mas nenhum caminho do produto os usa: o sincronizador grava pela fn_google_calendar, security definer executável só por service_role, com o privilégio do DONO da função — e grava title nulo desde a 0225 (v1.17.0): insere null e, no on conflict, faz set title=null, zerando o que encontra —, e o único privilégio de tabela do service_role que o produto usa é o DELETE da desconexão, com SELECT nas colunas do filtro (medido no invariante: título nulo, igual com todo privilégio de tabela do papel revogado, e a desconexão apagando); nenhuma função nem view que authenticated ou anon alcance cita o título ou a linha inteira do espelho — o grant de coluna fecha o login, e uma security definer com EXECUTE para authenticated devolvendo e.title entregava o título ao colega com todas as asserções de privilégio verdes (medido), então o invariante varre pg_proc e as views de public e reprova a alcançável; a única que cita o título é fn_google_calendar, só service_role; anon segue sem privilégio nesta tabela desde a 0177; e esta migration não apaga os títulos que sobraram de sincronizações anteriores à v1.17.0: o que se fecha é a LEITURA por login de usuário — do colega e também do próprio dono, já que nenhuma tela o mostra (vigiado por tests/unit/ocupacao-do-google-nao-expoe-titulo.test.ts, que casa a tabela e a view, e por tests/e2e/agenda-ocupacao-do-google-na-grade.spec.ts). Coluna nova no espelho nasce sem SELECT para authenticated e exige decisão explícita. O title não é o único dado pessoal do espelho, e o que fica aberto está escrito: external_calendar_id segue concedido a authenticated e, na agenda principal do Google, é o e-mail da conta conectada — o colega o lê pela tabela e pela view enquanto a RLS de calendar_connections esconde a conta dele (medido no invariante); external_event_id também segue concedido, e ical_uid — que não é identificador do Google, e sim o UID RFC 5545 gerado pelo sistema de quem criou o evento (lib/agenda/google/evento.ts) — é resíduo do mesmo período do title (só o cron anterior à v1.17.0 o gravava) e, ao contrário dele, NÃO é limpo ao ressincronizar: o on conflict de fn_google_calendar não o põe no set (medido no invariante); esta migration também não o apaga. Revogar a coluna derruba toda leitura da view por membro, a do dono inclusive, porque a view security_invoker a passa a fn_google_counts_for_conflicts. O que fecha sem mudar leitura nenhuma é trocar a policy de SELECT por "dono da conexão OU manager ou acima" (a régua de calendar_connection_calendars_select): as duas leituras de tela (app/app/agenda/page.tsx e app/api/v1/agenda/agendamentos/route.ts) já pedem a view pela sessão com calendar_connections!inner(user_id), cuja RLS esconde do não-gestor a conexão do colega — a grade de hoje JÁ não mostra ao Somente leitura e ao Atendente a ocupação do Google do colega (issue #879) —, e quem a entrega a todo membro é a security definer da 0260, que policy nenhuma alcança; o gestor já lê account_email em calendar_connections. Medido numa transação desfeita: não-gestor com 0 linha na tabela, na view e na tela, a função da 0260 com a ocupação, dono e gestor com a tela inteira. Muda QUEM lê o espelho: decisão do dono, fora deste conserto. (Uma versão anterior desta linha dizia que a policy era da organização de propósito, porque a grade mostraria a ocupação do colega; não mostra.) Vigiado no banco por tests/invariants/titulo-do-evento-pessoal-fora-do-alcance.test.ts: o estado da v1.26.0 reconstruído como controle; o bloco aplicado POR CIMA dele (o caminho do update.sh de um clone antigo) — permission denied no título, a leitura da tela de pé para o dono e o gestor (e vazia para o colega não-gestor antes e depois do bloco), a função da 0260 devolvendo a ocupação ao colega, view sem title, o service_role chamando o sincronizador, que grava title nulo pela definer — igual sem nenhum privilégio de tabela do papel —, e a desconexão apagando com o DELETE do papel (com controle de que o papel medido é o real) e o colega lendo o id do calendário; o banco instalado SEM reaplicar o bloco (o único que enxerga um grant de tabela posterior, e onde mora a varredura de funções e views alcançáveis, com controle de que ela pega três leitores do título e deixa passar quem só lê ocupação ou só o service_role executa); e o default ACL do Supabase simulado na transação antes do bloco (alter default privileges, com controle de que é a simulação, e não o dump, que chega à view recriada) — a view fechada para anon, o colega sem título pela tabela e pela view, o dono com a ocupação que a tela dele lê e sem título, e fn_agenda_ocupacao_google_do_dono (0260) devolvendo a ocupação. Os tipos de lib/database.types.ts tiram title da view. Baseline INSTALL/UPDATE idempotente. |
| 20260915180000 | 0262_cliente_pela_agenda | Cliente pela agenda, ligado por organização (PR #867, @423313). contacts.first_service_at = min(least(created_at, starts_at)) dos agendamentos cujo status conta (fn_situacao_conta_como_atendimento, espelho de LIBERAM_O_HORARIO: cancelado e falta não contam) — a data do primeiro horário que conta — o dia em que se combinou, ou o dia do atendimento quando ele for mais antigo —, nunca uma data futura. Recalculada por trg_agendamento_marca_cliente (INSERT), trg_agendamento_recalcula_cliente (UPDATE de status/starts_at/contact_id) e trg_agendamento_apagado_recalcula_cliente (DELETE) SÓ com settings.crm.cliente_pela_agenda = true. A etiqueta cliente tem dono (contacts.client_tag_by_system: added/removed/null, com CHECK): o sistema só tira a que pôs e só repõe a que tirou; a da equipe não se mexe. O dono é lido NA ESCRITA da etiqueta, pelo BEFORE UPDATE trg_contato_colunas_de_cliente (fn_colunas_de_cliente_sao_do_sistema) — que também RECUSA (42501) a escrita de sessão nas três colunas novas, medido antes: um viewer da própria organização gravava first_service_at = '2019-01-01' com UPDATE 1. fn_definir_cliente_pela_agenda devolve um quarto número, com_agendamento_que_nao_conta, sem o qual a tela dizia "nenhum contato tinha horário marcado" a uma agenda só de cancelamentos. contact.tag_added por emit_event, no formato do app, UMA vez por contato (contacts.client_recognized_at, que nunca volta a null); o repontamento de fn_mesclar_contatos, a ligação da regra e o DELETE nunca emitem. fn_definir_cliente_pela_agenda (admin, suporte de escrita, MFA; EXECUTE revogado de public e anon) grava a chave e classifica o histórico SÓ da organização que liga, sem evento por contato. Ordem de travas única: advisory da organização (compartilhado no trigger e em fn_mesclar_contatos, redefinida aqui só para pegá-lo antes dos contatos; exclusivo na ligação), depois o contato em for no key update — com for update e sem a trava na fusão, uma marcação comum e uma fusão fechavam deadlock detected com a ligação (medido). Nenhum backfill de classificação no apêndice; o único UPDATE de dados carimba client_recognized_at onde já havia first_service_at (zero linhas em instalação que nunca teve a coluna). crm_pipelines.is_client_pipeline + índice único parcial espelham is_default. As versões anteriores (backfill para todas as organizações, cancelado contando, etiqueta reposta, evento a cada virada) nunca estiveram em release nem na main. Gate: tests/invariants/cliente-nasce-do-agendamento.test.ts. |
| 20260919151000 | 0328_mensagem_do_lembrete | O texto do lembrete no WhatsApp passa a ser do tipo de agendamento (calendar_event_types.reminder_body). NULL = a frase padrão do cron, o comportamento anterior. Distinto de reminder_template_name (nome do template no provedor oficial). Aditiva e nullable; sem backfill. Superfície na tela de Tipos de agendamento. |
| 20260919151100 | 0329_mensagem_por_lembrete | Cada lembrete do tipo passa a ter texto próprio. calendar_event_types.reminder_bodies jsonb guarda o mapa minuto→frase dos degraus ADICIONAIS (reminder_body continua sendo o do principal). O teto de 3 extras da 0254 sobe para 20 em fn_degraus_de_lembrete_validos — quem opera escolhe quantos avisos o cliente recebe; 20 é guarda contra laço de formulário. Backfill copia reminder_body para cada extra já existente, para a atualização não trocar o texto que já saía. CHECK calendar_event_types_corpos_validos via fn_corpos_de_lembrete_validos. Superfície: lista dinâmica em Tipos de agendamento. |
| 20260919151200 | 0330_enderecos_salvos_da_agenda | Endereços reutilizáveis ao marcar (calendar_locations): salas e unidades da ORGANIZAÇÃO, unique por org + endereço normalizado. A tela de novo agendamento filtra a lista ao digitar e oferece salvar um endereço novo para os próximos horários. Leitura de membro, escrita agent+. Sem backfill: a lista nasce vazia e cresce com o uso (e também sugere o que já está em location_details de tipos e compromissos). |
| 20260918120000 | 0310_csv_como_material_de_conhecimento | CSV vira um formato de material de conhecimento — sem carregar SheetJS/exceljs. lib/ai/rag/extractors/csv.ts reusa decodificarCsv/parseCsv de lib/contacts/csv.ts (mesma defesa de encoding pt-BR e mesma recusa de .xlsx renomeado para .csv que a importação de contatos já tinha) para converter cada linha em um bloco Coluna: valor, que o chunker trata como um parágrafo por registro. O bucket ai-policy (migration 0014) foi criado com allowed_mime_types fechado em PDF/Markdown/texto, e como o insert daquela migration é on conflict (id) do nothing, nenhum clone já instalado recebe mudança de MIME dela de novo — este update é o follow-up idempotente que abre text/csv para quem já tem o bucket. .xlsx/.xls continuam recusados na BORDA (rota de upload e resolverExtensao), com a mesma instrução de "exporte como CSV" que lib/contacts/csv.ts já ensinava — decisão deliberada de não adicionar dependência de planilha binária para um recurso que todo Excel já exporta como CSV. Extraída do PR #928 de @cabindaferreira, que a numerou 0218 — número já ocupado na main. Aqui é 0310. O número desta migration já foi 0218 (do PR), 0271 e 0278: cada vez que ela esperou, a fila andou por baixo dela. Medido em 2026-09-18 cruzando git ls-tree de 902 refs locais com a origin/main: o maior número tomado em qualquer lugar é 0307 (0278–0283, 0290–0295 e 0305–0307 estão em branches represadas, nenhuma na main), então 0308 e 0309 ficam de folga e esta assume 0310. A folga é deliberada — renumerar custa mais que pular dois números. |
| 20260920020000 | 0363_recusa_permanente_da_agenda_nao_pede_repeticao | As três recusas PERMANENTES de fn_meet_action passam de 40001 para PT409 (recorte do PR #803, de @paulolimajr77). meet_stale, meet_conversation_stale e google_conflict_requires_choice não melhoram com nova tentativa — quem clicou precisa atualizar a tela —, mas 40001 promete o contrário, e quem acredita é a infraestrutura: o PostgREST mapeia a classe 40 para HTTP 500 e o gateway do Supabase reexecuta 5xx sem limite (o incidente de 2026-09-11, em docs/runbooks/postgrest-replay-do-gateway.md, pôs o produto inteiro em 503 PGRST002 por esse caminho). PT409 é a saída que o próprio runbook nomeia e que a guarda da 0250 já usa: o PostgREST lê os três últimos dígitos como status HTTP, e 4xx não é reexecutado. Não é 22023 (que já significa meet_action_invalid dentro desta mesma função) nem P0001 (que sairia como 400). Corpo idêntico ao da main, com três errcode trocados e mais nada — mesma ordem de guardas, mesmas travas, lock_timeout seguindo no PAPEL (0243). Sem tabela, coluna ou dado novo; create or replace, re-aplicável. |
| 20260920130000 | 0365_reenviar_o_link_e_acao_propria | Reenviar o link do Meet passa a ser uma ação própria (resend), e o deliver continua protegido (recorte do PR #803, de @paulolimajr77). Quem já enviou e precisa enviar de novo — cliente apagou a conversa, trocou de número — não tinha caminho pelo produto: o botão dizia "Link já enviado" e ficava desligado, e a saída era mandar o link à mão, fora do CRM, sem registro, sem fronteira de atendimento e sem auditoria. O return false do deliver em waiting_for_link/sent não foi afrouxado: ele é a proteção contra clique duplo, e perdê-la custaria envio em dobro ao cliente, que é pior que não-envio. Então deliver fica idêntico e resend é caminho próprio que passa reto, atrás de confirmação na tela e de rota própria. waiting_for_link e queued continuam trancando os dois (ali a entrega já está a caminho). O motivo da entrega fica FORA desta migration de propósito: quem o consome é fn_meet_delivery_enqueue, que só passa a copiá-lo na fatia da remarcação — gravá-lo agora seria evento sem consumidor. Empilha sobre a 0363: mesmo corpo, com o ramo do envio ampliado. create or replace, re-aplicável, sem tabela/coluna/dado novo. Invariante novo: tests/invariants/reenviar-o-link-e-acao-propria.test.ts. |
| 20260920140000 | 0366_o_compromisso_chega_sem_meet | O compromisso chega ao cliente mesmo sem Google Meet (recorte do PR #803, de @paulolimajr77). A entrega transacional já resolvia fila, canal, fronteira de atendimento e autorização — ela só não valia para compromisso PRESENCIAL ou POR TELEFONE, porque meeting_state='ready' e meeting_url is not null eram exigências INCONDICIONAIS em fn_meet_delivery_current. Passam a valer só onde location_kind='google_meet'. E fn_meet_action perde a guarda location_kind<>'google_meet' da recusa por meet_stale: com ela, autorizar o envio de um compromisso presencial era recusado como se a tela estivesse velha. A guarda de link NÃO vai para fn_meet_action, e o autor deixou medido por quê: ele tentou, e ela derrubou 10 casos legítimos do invariante do Meet — a tela oferece "Enviar quando ficar pronto" e a entrega fica em waiting_for_link até o link chegar; quem garante que reunião sem porta não sai é o enfileirador. O que NÃO muda: atendimento aberto na conversa, contato anonimizado/bloqueado fora, papel conferido no ENVIO (não no clique), e link obrigatório onde o local é o Meet. Duas create or replace, sem tabela/coluna/dado novo, re-aplicáveis; apêndice do baseline acima da varredura de anon. |
| 20260921050000 | 0374_remarcar_corrige_o_envio | Remarcar um compromisso JÁ ENVIADO passa a corrigir o cliente sozinho (recorte do PR #803, de @paulolimajr77). O autor mediu a cadeia peça por peça: o gatilho de remarcar sobe a revisão e não toca em meeting_delivery; o de enfileirar só age em waiting_for_link; fn_meet_action('deliver') devolve false em sent; e a tela desabilitava o botão. Resultado: o cliente recebia um horário, alguém remarcava, nada saía, e a pessoa aparecia no dia errado — nenhuma varredura cobria (a fn_appointment_confirmation_sweep só age DEPOIS do compromisso e cria aviso interno, nunca mensagem ao cliente). Gatilho novo trg_remarcar_corrige_o_envio (BEFORE UPDATE) com três guardas contra virar spam: só quem está em sent (em waiting_for_link/queued a mensagem ainda sai com o horário novo sozinha), só quando starts_at/time_zone mudam (senão editar o TÍTULO mandaria mensagem), e espera de 2 min em nao_antes_de — com a remarcação seguinte SUBSTITUINDO a anterior, porque o enfileirador passa a matar o job pendente da geração anterior. A autorização é herdada de quem autorizou o envio original (mesma intenção, corrigida) e a vigência reconfere papel e responsável no envio. fn_meet_delivery_enqueue passa a carregar motivo no payload (ausente = primeiro_envio, o comportamento antigo) e a respeitar nao_antes_de no run_after; NÃO recria o gatilho, para não reabrir a janela de apagar-e-criar da Onda 1. Sem tabela, coluna ou dado novo. |
| 20260919140000 | 0324_destinos_internos_da_instalacao | A lista de destinos internos que o dono da instalação autoriza sai do .env e ganha tela (decisão 22-d, #1004; PR #1055, @webtecnica). platform_settings.internal_destinations text[]: IPv4 e faixas CIDR IPv4 que a instalação pode alcançar mesmo sendo rede interna, editados em /admin/destinos-internos. null = a tela nunca foi usada e vale o piso IA_DESTINOS_INTERNOS_PERMITIDOS do .env; '{}' = o dono esvaziou a lista, e nada passa — os dois estados são distintos para o dono poder revogar pela tela o que o .env autorizou. Não guarda nome: a decisão compara o IP que o nome resolve (item 4 da #1004). A lista vale só para destino configurado pela INSTALAÇÃO (hoje, TRANSCRIPTION_BASE_URL) e dispensa só a recusa por endereço interno — esquema e https em produção continuam valendo (lib/automation/destinos-internos-autorizados.ts). A tabela já é só do servidor (0253); coluna nova herda RLS sem policy e os grants. Sem função nova, sem backfill, sem dado tocado. Apêndice do baseline idempotente. |
| 20260916202500 | 0267_espera_da_fila_nao_recomeca | A aba Fila deixa de mandar para o fim quem insiste (issue #990). A ordem era por last_inbound_at — a última mensagem do cliente —, e essa coluna é reescrita a cada mensagem (fn_mark_conversation_message faz greatest(last_inbound_at,p_at)): a espera de quem pergunta de novo RECOMEÇA, e ele afunda atrás de quem escreveu uma vez e ficou quieto. Cenário da issue (A escreve 10h00, B escreve 10h05, A escreve 10h10) mostrava B na frente de A. A régua passa a ser a mensagem do cliente mais antiga desde a última resposta enviada, em coluna própria (conversations.awaiting_since) porque a ordem da Fila é um order by pedido ao PostgREST pela rota da lista, e o PostgREST ordena por COLUNA — a expressão mora em messages e depende de last_outbound_at. A decisão de ordenar por espera continua a de 16/09 (#639): muda a régua, não a decisão. Os TRÊS lugares que falam de espera passam a ler esta coluna — a ordem da lista, a pílula "Aguardando há…" da linha (components/inbox/ConversationListItem.tsx) e a posição entregue às ferramentas de IA (lib/routing/queue.ts, via o MCP de handoff) —, que antes divergiam: a pílula e a hora do canto liam last_inbound_at e mantinham o defeito na tela mesmo com a lista ordenada. awaiting_since carrega o valor de hoje (last_inbound_at) quando não há mensagem sem resposta — a bola está com o cliente —: são as linhas que nunca tiveram o defeito, e é deliberado, porque NULL ordena por ÚLTIMO e a conversa que hoje aparece no meio da fila cairia para o fim na atualização. Preenchimento em duas passadas: a primeira copia last_inbound_at para quem já está respondido (sem tocar em messages); a segunda gasta um min() por conversa apenas para quem tem mensagem sem resposta, servido por idx_messages_conversation_sent (conversation_id, sent_at DESC) — o custo caro é proporcional às conversas em espera, não ao tamanho de messages, e a guarda awaiting_since is null torna a reaplicação um no-op. Corpo de fn_mark_conversation_message DERIVADO da versão em vigor (lock de serviço, guarda de troca de contato, fronteira de service_closed_at), e fn_reply_record_receipt — a resposta aprovada escreve last_outbound_at à mão e não passa pela função de carimbo — recebe awaiting_since=last_inbound_at no bloco em vigor, senão a espera encerrada pela aprovação ficaria congelada. Sem constraint nova, sem índice novo, sem coluna gerada (a régua depende de messages, que muda depois). Gate: tests/unit/fila-nao-empurra-quem-insiste.test.ts. |
| 20260915213849 | 0264_vocabulario_de_tags | O vocabulário de etiquetas deixa de ser só de leitura (issue #852, fatia S4). Três funções em public: fn_vocabulario_de_tags (leitura, security invoker, devolve tag, uso_em_contatos, uso_em_leads, uso_em_conversas, em_regras, cor, descricao, no_vocabulario a partir dos três text[] e das sementes de organizations.settings), fn_tags_normalizar (pura/imutável, renomeia/junta/exclui numa lista desduplicando por nome canônico — é o que impede a junção {"vip","VIP"} de virar duplicata) e fn_vocabulario_de_tags_operar (a operação inteira numa transação: os três arrays, as sementes da organização e as ações add_tag de automation_rules, com fn_role_at_least(p_org,'manager') antes de qualquer escrita e emit_event por linha alterada). Renomear e juntar reescrevem a regra do agente; excluir NÃO apaga a regra — informa quantas continuam escrevendo (decisão de outra tela). Sem tabela nova, sem coluna nova, sem backfill: o vocabulário continua sendo text[] + jsonb em settings, porque automação, webhook (lead.tag_added) e MCP (*.tags_changed) já falam em string. revoke execute ... from public, anon nas três e grant só a authenticated/service_role. |
| 20260917120000 | 0271_extensoes_declarativas | Cinco tabelas do framework declarativo: catálogo admitido, artefato imutável, instalação por origem/identidade, vínculo por organização e recibo durável. Oito RPCs service-only (admitir, preparar, concluir, falhar, cancelar, configurar, desfazer a última troca, remover da instalação) com ator vigente, fingerprint idempotente, applied_now que distingue a transição da repetição, e CAS pela revisão da instalação; mais a contagem de organizações ativas por instalação, que devolve só números. A instalação ganha previous_artifact_id (histórico de um passo), revision e remoção lógica (removed_at/removed_by); o vínculo ganha deactivated_by_removal_at. As CHECKs de kind/status do recibo são constraints nomeadas com drop/add, então um banco de desenvolvimento com a versão antiga do bloco se corrige ao reaplicar o apêndice (a cadeia db push não reaplica). Publicar espera a atualização do core (dispatched com menos de 15 min); remover não espera. prepare/finish/cancel/revert/remove e o gatilho de system_update_runs compartilham a trava consultiva; configurar serializa com a troca de ponteiro por for share na instalação. RLS de membership somente para leitura do vínculo; escrita direta do framework revogada inclusive de service_role. Apêndice idempotente. |
| 20260915193743 | 0263_etapa_de_perda_grava_o_motivo | A etapa de PERDA do lote grava o motivo na MESMA escrita. fn_mover_leads_em_lote (0209) trocava só o stage_id; quando a etapa de destino é de perda, trg_crm_lead_close_on_stage fecha o negócio e a CHECK crm_leads_lost_reason_required recusa a linha — e, como a função é uma transação só ("move todos ou não move nenhum"), UM card sem motivo derrubava o LOTE INTEIRO, com a rota respondendo 500 (internal_error) a quem acabara de pedir o movimento. Medido no baseline (pg16, lote de 2 cards abertos para a etapa de perda): SQLSTATE=23514 | new row for relation "crm_leads" violates check constraint "crm_leads_lost_reason_required". O quarto parâmetro p_lost_reason é a mesma escrita — uma escrita ANTES tem janela (entre as duas o negócio aparece lost sem motivo) e uma DEPOIS é linha já recusada. A coluna só entra na lista do update quando há motivo (SQL dinâmico) porque tocar lost_reason dispara trg_validate_lost_reason_required, que confere o valor contra o vocabulário do funil: reescrever o valor que já estava na linha recusaria um card cujo motivo saiu da configuração depois de usado (22023 lost_reason_invalid) enquanto o arrasto do mesmo card — que não toca a coluna — continuaria passando. coalesce com o valor da própria linha preserva o motivo do card que já era perdido (p_lost_reason nulo não apaga nada). drop function antes do create or replace porque o PostgreSQL não substitui assinatura (sem ele, fn_mover_leads_em_lote(uuid, uuid[], uuid) is not unique). Sem coluna nova, sem backfill, sem dado tocado; baseline com o corpo final. Os três caminhos ficam com uma só decisão (lib/leads/motivo-da-perda.ts): arrasto (/leads/[id]/move), lote (/leads/bulk) e a IA (lib/leads/agent-stage-sync.ts — o agente não move: recusa de negócio com item de inbox acionável, porque lost_reason não é texto livre e o motivo é decisão de quem está no negócio). Catracas: tests/unit/etapa-de-perda-no-arrasto.test.ts, tests/unit/etapa-de-perda-no-lote.test.ts e tests/unit/etapa-de-perda-do-agente.test.ts (vermelhas sem o fix). |
| 20260919152000 | 0331_comportamento_da_instalacao | O COMPORTAMENTO da instalação ganha coluna e tela (issue #1034). Quatro decisões que valiam para a instalação inteira e só se tomavam por SSH passam a morar na linha única de platform_settings: orcamento_de_ia (ex-AI_BUDGET_ENFORCEMENT), exigir_assinatura_no_webhook (ex-WAHA_WEBHOOK_REQUIRE_SIGNATURE), divulgacao_de_pagamento (ex-DISCLOSURE_MODE) e promessa_semantica (ex-PROMISE_SEMANTIC_ENABLED). As quatro NASCEM NULAS, e isso é a decisão que faz esta migration não mudar comportamento nenhum: null significa "esta instalação nunca opinou" e quem responde é o arquivo de ambiente, o PISO — mesma divisão de papéis da 0253. Um default já nasceria vencendo o .env, e a instalação que hoje declara AI_BUDGET_ENFORCEMENT=off passaria a bloquear gasto no primeiro deploy. As CHECKs são escritas com is null or ...: recusam valor que ninguém reconhece sem obrigar a instalação a ter opinião. RLS ligada com ZERO policies e o mesmo grant da 0253 (só service_role), porque a linha não pertence a organização nenhuma; nenhuma linha é semeada — quem cria é o primeiro salvamento, que é upsert. Baseline PG15, idempotente (add column if not exists). Tela em /admin/sistema ("Comportamento"), porta no AdminSidebar, audit() em toda escrita. Ver lib/instalacao/comportamento.ts. |
| 20260918014500 | 0277_pais_da_organizacao | O país da organização: onde moram o documento do titular, a lei citada e o prazo do direito de acesso (issue #1033, decisão do dono 25-a). organizations.country (ISO-3166 alpha-2 em maiúsculas, CHECK country is null or country ~ '^[A-Z]{2}$'), sem default e sem backfill: null é Brasil, que é o comportamento de antes desta migration — nenhuma linha existente é reescrita e nenhum DEFAULT de outra coluna muda, então quem já instalou continua exatamente onde está. A localização é por ORGANIZAÇÃO, e não por instalação: duas organizações no mesmo banco podem estar em países diferentes (APP_COUNTRY no .env responderia por processo, e o processo serve todas). Nada de RLS, grant ou policy: a coluna nasce na tabela que já tem as regras dela. O perfil do país é CÓDIGO, não configuração de instalação: lib/legal/perfil-do-pais.ts (rótulo e validador do documento, lei citada, calendário de dias úteis e padrões de PII), resolvido por perfilDaOrganizacao(supabase, orgId) no formato de lib/catalogo/moeda-da-org.ts — nenhuma rota lê a coluna inline, porque duas leituras divergem no dia em que uma ganhar fallback e a outra não, e aqui a divergência prometeria a lei de um país com o prazo de outro. A régua para um país entrar (risco escrito na issue): o documento responde a um direito legal do titular, e citar a lei errada é pior do que não citar artigo nenhum — país entra com a citação REVISADA, ou não entra; paisesOferecidos() (o que o seletor mostra) exige lei.revisada, e o registro pode conhecer mais países do que a lista oferece. Sem citação revisada o documento não cita lei nenhuma (não há fallback para a LGPD). Sem coluna nova além desta, sem tabela nova, sem seed de feriado (o calendário vem versionado com o produto, em lib/lgpd/holidays-br.ts). O apêndice do baseline entra ANTES do bloco da VARREDURA anon, com add column if not exists + drop constraint if exists antes do add constraint (quem ATUALIZA precisa da trava de forma tanto quanto quem instala do zero). Gate: tests/unit/pais-da-organizacao-governa-documento-e-lei.test.ts. |
| 20260919103000 | 0322_automacao_tem_numero_proprio | A mensagem que a automação manda ganha número próprio no painel de atrito (#652). O carimbo messages.sent_via passou a gravar 'automation' quando quem manda é a automação (origemDaMensagem, em app/api/v1/messages/_handler.ts) — antes a pergunta era uma só (!== "user") e um template fixo de regra saía 'ai', sem IA nenhuma no caminho. Efeito medido: a linha deixou de somar em envios_por_ia, e por isso fn_atrito_metrics ganha por_automacao no CTE envios e a chave envios_por_automacao em empresa, publicada no painel em Contenção com a nota que explica de onde ela vem (regra, texto fixo do follow-up e lembrete de agenda) e por que o número do agente cai onde há automação (lib/metrics/atrito.ts, montarPares). ADITIVO: nenhuma chave atual muda de nome; por_ia, por_humano_no_sistema e por_humano_fora seguem idênticos. A fórmula de "Respostas dadas pelo agente" (taxaDeAutomacao) NÃO mudou — incluir a automação no denominador baixaria a taxa de quem usa regra, e essa é a decisão que ficou aberta no PR, medida e declarada, não tomada em silêncio. Quem lê o número: operador de VPS com automação ligada — o "por IA" dele cai na proporção do que a regra manda, e agora existe o número que explica a queda. Teste de banco: pnpm test:db (invariante de atrito), prova no CI; aqui não há Docker. Corpo derivado da versão em vigor no baseline.sql (o MANIFEST proíbe recriar fn_atrito_metrics de memória: ela carrega prazos e denominadores das migrations 0133/0134/0135/0137). |
| 20260917034929 | 0268_regra_de_automacao_guarda_o_gatilho | A regra passa a guardar a CONFIGURAÇÃO do gatilho (automation_rules.trigger_config jsonb not null default '{}'), e isso existe por causa de um gatilho que não nasce de evento: "quando faltarem N dias para uma data do funil" (issue #989). Quem o emite é a varredura app/api/v1/cron/lead-date-field-due, e ela só sabe onde olhar se a regra disser QUAL funil e QUAL campo de data, mais o N assinado (positivo = antes da data, negativo = depois). jsonb, e não três colunas: pipeline_id/campo ficariam vazios em 100% das regras dos outros nove gatilhos, e o gatilho seguinte pode precisar de outra coisa — é o mesmo desenho de conditions e actions, com o contrato de forma no TypeScript (ConfigDoGatilhoDeData). Sem CHECK, de propósito: recriar o CHECK a cada gatilho novo não é idempotente; a porta única é createAutomationRuleSchema (recusa a regra de data sem configuração) e a varredura pula o que chegar torto, contando em pulados.config_invalida, sem derrubar as outras organizações. '{ }' como default faz toda regra existente nascer com o objeto vazio — sem backfill e sem uma terceira forma de "sem configuração". notify pgrst porque a varredura SELECIONA a coluna. Gate: tests/unit/data-do-funil-avisa-a-regra-certa.test.ts. |
| 20260916120000 | 0266_transferencia_entre_funis_nao_e_perda | A transferência entre funis não é perda comercial — e a regra de automação transfere em vez de abrir um segundo negócio (issue #992). Duas metades do MESMO nome. (1) fn_validate_lost_reason_required ganha o canônico moved_to_another_pipeline, o motivo PRÓPRIO da troca de funil: em lib/leads/motivo-da-perda.ts ele vira MOTIVO_DA_TRANSFERENCIA e passa a ser o padrão da origem encerrada (antes, other) — sem ele no array do trigger o encerramento volta com 22023 lost_reason_invalid, a origem fica aberta no funil antigo e o negócio existe nos dois lugares; é o defeito que a troca existe para consertar. (2) fn_attendant_metrics e fn_atrito_metrics deixam de contá-lo: count(*) filter (where status = 'lost' and coalesce(lost_reason, '') <> 'moved_to_another_pipeline') — quem foi levado para outro funil não é perda de ninguém, e o contador por responsável para de engordar com movimento administrativo. Sem coluna, sem constraint, sem backfill: as três funções são create or replace puros e o estado anterior segue válido (negócio já fechado com other continua contando como perda — a migration não reescreve histórico); o apêndice do baseline entra antes do bloco da varredura anon. Corrige também o texto da P-01 em docs/business-rules/00-business-rules-catalog.md, que citava moved_to_pipeline_X, o motivo que o banco recusa. Catraca: tests/unit/automacao-troca-de-funil-transfere-o-negocio.test.ts. |
| 20260917150401 | 0273_zona_de_perigo_apaga_com_resposta_revisada | A Zona de perigo volta a apagar dados operacionais de organização que já enviou resposta revisada (issue #949). ai_reply_drafts.message_id foi criada pela 0227 como references public.messages(id) SEM ação de exclusão — NO ACTION —, e era a ÚNICA das quatro FKs para public.messages(id) fora do padrão on delete set null das irmãs (ai_agent_suggestions.message_id na v. 11337, messages.reply_to_message_id na 14759, calendar_appointments.outcome_message_id na 19939). O apagamento da Zona de perigo (lib/settings/apagar-dados-operacionais.ts) começa por messages — é a ordem declarada em RAIZES_DO_APAGAMENTO, porque messages.contact_id é RESTRICT para contacts —, então o PRIMEIRO delete da organização era recusado pelo banco: medido, SQLSTATE=23503 | update or delete on table "messages" violates foreign key constraint "ai_reply_drafts_message_id_fkey" on table "ai_reply_drafts". O app traduz isso no primeiro item de ResultadoDoApagamento com ok: false, tabela: "messages", e nada é apagado: a organização fica intacta e o operador vê a ação falhar sem causa visível. A ação escolhida é set null, e a evidência é o padrão das irmãs: a irmã que passou pela mesma decisão escreveu o porquê no próprio cabeçalho — "apagar a citada não pode levar junto a resposta, que é conteúdo próprio. Perder o fio é aceitável; perder a resposta é apagar histórico por causa de um ponteiro" (14759) — e ai_reply_drafts guarda conteúdo próprio: approved_body, edited_by, o trace da decisão e o feedback humano. Por que cascade foi preterido: existe caminho legítimo que apaga MENSAGEM por motivo alheio à resposta — a deduplicação de eco (delete from public.messages ... sent_via = 'external_device' ..., v. 21770) e a exclusão de uma mensagem avulsa pela UI (app/api/v1/messages/_handler.ts) —, e ali cascade apagaria um rascunho revisado como efeito colateral de limpeza de ponteiro, exatamente o que a doutrina da 14759 proíbe; além disso cascade não acrescenta nada à promessa da Zona de perigo, porque quem leva os rascunhos da organização é o cascade de conversations (ai_reply_drafts.conversation_id). Estado final do caminho do defeito idêntico ao prometido — messages cai, conversations cai e leva os drafts —, sem o 23503 no meio. message_id é nullable (null sempre significou "ainda não enviado" para o filtro de leitura), então não há DEFAULT nem backfill. Nome do constraint preservado (ai_reply_drafts_message_id_fkey), que é o que aparece na mensagem de erro citada no suporte. Caminhos irmãos que herdavam a mesma recusa, levantados por grep (nenhum arquivo de TypeScript foi tocado; grep -rl 'from("messages")' app lib cruzado com .delete()): exclusão de contato (app/api/v1/contacts/_handler.ts apaga messages antes de conversations e contacts — a recusa virava o 409 enganoso de "o contato ainda tem registros vinculados") e exclusão de mensagem avulsa (app/api/v1/messages/_handler.ts). As rotas de admin (admin/tenants/[id]/route.ts, admin/inbox/conversations/[id]/route.ts) não entram: elas só contam/leem messages. A cascata de LGPD NÃO entra na lista: fn_lgpd_cascade_redact_contact anonimiza, não apaga messages — o único outro delete from public.messages em SQL é o do eco (v. 21770). Baseline INSTALL/UPDATE idempotente (drop constraint if exists + add constraint). Gate: tests/invariants/zona-de-perigo-apaga-com-resposta-revisada.test.ts. |
| 20260917150000 | 0274_travas_de_suporte_cobrem_toda_tabela | As travas do modo somente leitura do suporte cobrem toda tabela da organização já na primeira aplicação do schema, e a instalação nova chega ao mesmo conjunto de travas que a atualização. A enumeração da 0220 que planta as restritivas support_write_{insert,update,delete} vira public.fn_aplicar_travas_de_suporte() (sem parâmetro, idempotente: drop policy if exists + create policy), chamada depois de toda tabela — na cadeia de migrations, por esta migration; no baseline.sql, a definição fica antes da varredura de anon e a chamada é o último bloco do arquivo (o do avulso do bloco da 0220 saiu, e um comentário aponta para cá). Regra de seleção inalterada: tabela comum de public, RLS ligada, com organization_id (ou organizations, pela id); gravável por authenticated → as três restritivas, só do servidor → nenhuma. Não é security definer; revoke execute de public, anon, authenticated, service_role, sem grant — só quem aplica o schema a chama. Medido em pg17 descartável com o prelúdio do scripts/test-db.sh, baseline aplicado UMA vez: toda tabela alcançada pela regra tem as três travas, e o conjunto de políticas é o mesmo de duas aplicações (md5 idêntico). Sem tabela, coluna ou dado novo. Invariante: tests/invariants/travas-de-suporte-cobrem-toda-tabela-na-instalacao.test.ts, que lê um molde de aplicação única tirado pelo scripts/test-db.sh entre o install e o update — e prova que ele é de uma aplicação pelo contador test_db.aplicacoes_do_baseline, que o script grava a cada aplicação. |
| 20260918100000 | 0279_caso_so_nasce_do_motor | Fecha as duas origens de escrita de agent_cases, agent_case_events e conversation_assignment_events para authenticated, e reserva ai.case_opened/ai.case_closed no emit_event. As três nasceram graváveis por qualquer membro porque o ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO "authenticated" (baseline.sql:4727) precede a criação delas e as policies eram for all/for insert sem papel mínimo (tenant_isolation_agent_cases_all, tenant_isolation_agent_case_events_insert e cae_insert — refaça a medida com grep -nEi 'policy .*(agent_cases|agent_case_events|cae_insert)' supabase/baseline.sql, que hoje só devolve o bloco desta migration e as duas de SELECT). O baseline deixou de CRIAR as três para derrubá-las no fim: criar e derrubar no mesmo arquivo faz a regra antiga valer entre os dois pontos de toda instalação, e tests/unit/baseline-nao-constroi-o-que-derruba.test.ts reprova. O par drop+create mora colado no bloco desta migration, e não no ponto de criação original, para que um update.sh sobre banco vivo não deixe a tabela sem policy nenhuma por 20 mil linhas de DDL. Um viewer da própria organização escrevia, falando direto com o PostgREST e com o JWT dele, o título e o resumo que a IA redigiu sobre o atendimento de uma pessoa — o texto que a equipe lê para decidir; e um INSERT forjado em conversation_assignment_events faz o histórico de dono dizer que alguém assumiu um atendimento que ninguém assumiu. Nenhum caminho legítimo escreve por authenticated (censo no corpo do PR): o motor usa pg.Pool (lib/agent-engine/agent/human-cases.ts), o cron usa service role (app/api/v1/cron/case-stale-watcher/route.ts), e a troca de dono das cinco rotas passa por fn_conversation_assign, que é security definer e insere com o privilégio do DONO — por isso claim/transfer/release seguem funcionando pelo client de sessão (controle positivo do invariante). O reset da Zona de perigo apaga conversations e as três somem por CASCADE, que também roda como o dono. SELECT continua aberto nas três: a tela, o MCP e o motor leem. A policy for all de agent_cases vira for select to authenticated — e ela precisa CONTINUAR existindo, porque tests/invariants/rbac-config-ia-canais.test.ts exige que toda tabela da allowlist de dívida apareça em pg_policies. A reserva no emit_event sozinha não fecha nada — ela só barra quem tem auth.uid(); quem fecha a forja da linha é o revoke, e por isso as duas andam na mesma migration. O corpo do emit_event é o VIGENTE derivado (a definição de maior número de linha no baseline, conferida idêntica à última da cadeia, em 0224_presenca_e_recuperacao), com a única mudança sendo a lista de tipos reservados; a mensagem fica com o nome herdado (reserved_message_received) porque renomeá-la é mudança de contrato observável. Fecha com fn_aplicar_travas_de_suporte() (0274), que dá ZERO policies support_write_* a tabela server-only — o contrato mais restritivo. Número 0279 e não 0277/0278: os dois estão tomados por branches locais de triagem (triagem/714-smtp-como-opcao e triagem/recorte-928) — medido sobre todas as refs, e o hook check-migration-triple.sh reprovou o 0278 no ato do commit. Sem tabela, coluna ou dado novo. Gate: tests/invariants/caso-so-nasce-do-motor.test.ts. |
| 20260918101000 | 0280_anonimizar_alcanca_o_caso | A cascata de LGPD passa a alcançar o que a IA escreveu SOBRE a pessoa quando o atendimento travou. fn_lgpd_cascade_redact_contact percorre uma lista escrita à mão e quatro tabelas nunca entraram nela: agent_cases (title vira o rótulo, summary/blocker viram texto fixo, context_snapshot vira {}), agent_case_events (body nulo, metadata {}), demandas (assunto nulo) e agent_inbox_items (resolve, corpo fixo Contato anonimizado. e ref_id nulo). O modo de falha era mudo: a rota devolvia SUCESSO, a contagem por tabela fechava, o SLA de D+15 era marcado como cumprido e o relato continuava legível — com o nome de quem pediu para ser esquecido dentro do aviso que a Central mostra. agent_cases.updated_at fica FORA do set, de propósito: o cobrador de caso parado (app/api/v1/cron/case-stale-watcher/route.ts) o lê como "alguém da equipe encostou neste caso", e escrever ali faria a anonimização ADIAR a cobrança de um caso que continua parado. O vínculo de agent_inbox_items tem TRÊS braços, medidos nos produtores: handoff nasce com ref_kind='contact' (lib/ai/handoff/orchestrator.ts) E com ref_kind='conversation' (lib/agent-engine/agent/inbound-turn.ts); case_stale nasce SEMPRE com ref_kind='agent_case' (a rota do cron acima, e a política em lib/ai/inbox-destino.ts) — um predicado com os dois primeiros braços casaria ZERO avisos de caso parado, e casar zero linha não é erro: é sucesso com o texto intacto. Os kind são os do CHECK vigente (case_opened não existe; reconfira com grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql). Cada passo PRESERVA o que é operação — estado, dono, marcas de tempo, contagem: um passo que apagasse a linha inteira ficaria verde num teste de "o texto sumiu" e tiraria da organização a resposta a "quantos atendimentos pararam em março". O corpo é o VIGENTE derivado (a definição de maior número de linha no baseline, copiada por script — provado: removendo cabeçalho, passos novos e o notify, o texto restante é byte a byte o de antes). lib/lgpd/export-collector.ts ganha os três blocos correspondentes (cases, case_events, demandas), porque tests/unit/lgpd-exporta-o-que-redige.test.ts deriva as duas pontas da fonte e reprova sozinho sem eles — o que se apaga a pedido do titular é o que se entrega a pedido dele. Sem tabela, coluna ou dado novo; lib/database.types.ts não muda. Gates: tests/invariants/lgpd-caso-anonimiza.test.ts (o efeito) e tests/invariants/cascata-lgpd-nao-encolhe.test.ts (a catraca da lista nomeada, nos DOIS sentidos — a função é reescrita por cinco entregas e o Postgres troca o corpo INTEIRO). Número 0280 e não 0278: 0277 e 0278 estão tomados por branches de triagem e 0279 é a migration da onda anterior — medido sobre todas as refs. |
| 20260918102000 | 0281_conversa_do_caso | Onde a consulta da equipe à IA sobre um caso fica guardada, e por que não cabia em lugar nenhum que já existia. Cria agent_case_chat_messages (uma linha por mensagem, turn_id agrupando pergunta e resposta) porque as três alternativas óbvias quebram consumidores vivos: conversation_notes é lida por lerContinuidadeHumana e viraria instrução literal no prompt do agente que fala com o CLIENTE; agent_case_events com actor_kind='human' infla intervencoes e zera espera_fila em fn_atrito_metrics — o Índice de Atrito passaria a medir LEITURA em vez de DECISÃO; e qualquer escrita em agent_cases mexeria em updated_at, que é o sinal de "alguém encostou" do case-stale-watcher. A coluna de texto chama-se body de propósito, e há contact_id com FK: são as DUAS condições que tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts exige para enxergar a tabela — com pergunta/resposta ela nasceria invisível ao gate. A redação entra dentro de fn_lgpd_cascade_redact_contact (trigger novo com outro nome não conta para aquele instrumento), derivada por script do corpo vigente; o export ganha o bloco pareado (tests/unit/lgpd-exporta-o-que-redige.test.ts reprova quem redige e não exporta); e fn_expurgar_conversa_do_caso_vencida (365 dias, piso de 90 no corpo) entra no cron diário de retenção. RLS for select com TRÊS condições — organização, fn_role_at_least('agent') e fn_can_view_conversation —, revoke all from anon, authenticated antes do grant select (o ALTER DEFAULT PRIVILEGES do baseline precede toda tabela de apêndice) e zero caminho de escrita pelo PostgREST: quem escreve é o servidor. A idempotência do POST é a unique (organization_id, case_id, turn_id, author_kind) e não idempotency_keys — aquele helper grava response_body, que seria uma cópia da resposta sobre a pessoa numa tabela fora da cascata e sem expurgo (grep -rn "idempotency_keys" app/api/v1/cron lib/retencao lib/lgpd → vazio; rode para conferir). Para ver a lista de tabelas que a cascata alcança hoje, sem acreditar nesta linha: psql -c "select distinct m[1] from pg_proc p, lateral regexp_matches(pg_get_functiondef(p.oid), '(?:update\|delete from)\s+(?:public\.)?\"?([a-z_]+)\"?','gi') m where p.proname='fn_lgpd_cascade_redact_contact' order by 1". |
| 20260918110000 | 0291_passagem_para_humano_tem_registro | Toda passagem do atendimento automático para uma pessoa vira uma linha de fato. Cria passagens_de_atendimento — uma linha por episódio, com o motivo (vocabulário fechado, traduzido na tela por lib/escalacao/passagem.ts → FRASE_DO_MOTIVO), o que a IA já tentou (tentativas jsonb, validado por Zod ANTES do insert), o que ela entendeu que o cliente quer (title), a narrativa que quem assume lê (body), as PALAVRAS LITERAIS do cliente (notes), o texto livre de quem passou (content), a VERDADE sobre o aviso ao cliente (cliente_avisado + aviso_motivo_codigo, também fechado) e o par de reconhecimento. Hoje nada disso sobrevive ao turno: o motor A monta um resumo que morre no corpo do aviso da Central e o motor B abre o aviso SEM resumo nenhum — quem assume relê a conversa inteira e o cliente repete o que já disse. As quatro colunas de texto se chamam title/body/notes/content DE PROPÓSITO, e há contact_id com FK: são as duas condições que tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts exige para enxergar a tabela — com resumo/motivo_texto/cliente_quer (o desenho anterior) ela nasceria INVISÍVEL ao gate e a cobertura dependeria de um invariante comportamental que uma sessão futura pode apagar com o gate de classe verde. caso_id é on delete set null e não cascade: apagar o caso não pode apagar o fato de a conversa ter ido para uma pessoa. RLS for select com TRÊS condições — organização, fn_role_at_least('agent') e fn_can_view_conversation (é por isso que conversation_id mora dentro) —, revoke all from anon, authenticated antes do grant select, e ZERO caminho de escrita pelo PostgREST: uma passagem forjada por um membro diria que a IA desistiu de um atendimento que ela nunca tocou. Os CHECK são criados inline E redeclarados por drop constraint if exists + add com o MESMO nome que o Postgres dá ao inline — auto-cura sem duplicar, porque duas constraints definindo o mesmo vocabulário fazem vocabulario-banco-x-typescript.test.ts se RECUSAR a medir. fn_expurgar_passagens_vencidas (1825 dias, piso de 90 no corpo) entra no cron diário de retenção como sexta poda e só apaga passagem JÁ RECONHECIDA: passagem aberta é alguém esperando resposta, e apagá-la por idade seria o expurgo virando esquecedor de pendência. A cascata de LGPD ganha o passo da tabela (body é not null e recebe o RÓTULO, como voice_calls.peer_phone) e conversations.last_handoff_reason = null dentro do passo 2 que já visita as mesmas linhas — o corpo é o VIGENTE derivado por script, com as duas edições provadas reversíveis byte a byte. lib/lgpd/export-collector.ts ganha o bloco pareado, porque tests/unit/lgpd-exporta-o-que-redige.test.ts reprova quem redige e não exporta. Ninguém escreve nesta tabela ainda: os 13 call sites dos dois motores, o reconhecimento automático por fn_conversation_assign e o cartão na conversa são das ondas seguintes. Gates: tests/invariants/passagem-isolamento-e-visibilidade.test.ts, lgpd-cascata-alcanca-a-passagem.test.ts, passagem-retencao.test.ts, e as três listas nomeadas (rls-isolation → TABLES, vocabulario-banco-x-typescript → quatro pares, cascata-lgpd-nao-encolhe → a catraca). Número 0291 e não 0281: as ondas anteriores consumiram 0279–0281 — medido sobre todas as refs com git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*'. |
| 20260918121000 | 0292_aviso_de_caso_no_whatsapp | O WhatsApp da equipe passa a ser avisado quando a IA abre um caso — e o aviso tem registro de entrega. Cria config_aviso_de_caso (UMA linha por organização: para onde mandar, por qual conexão, ligado ou não, mais os dois contadores do descarte) e entregas_de_aviso_de_caso (UMA linha por organização+caso+destino). É a unique dessa segunda que dá a IDEMPOTÊNCIA: o dreno do event_log reentrega o mesmo evento em retry e há três drenos em processos diferentes — sem ela a equipe receberia o mesmo aviso uma vez por tentativa. O texto do aviso NUNCA é guardado (só corpo_hash, precedente send_ledger.body_hash): guardá-lo seria uma segunda cópia do relato do cliente numa tabela que a cascata teria de aprender a redigir. A única coluna capaz de ecoar um dado pessoal é erro_detalhe (o texto cru do transporte), e é ela que a cascata zera. NÃO existe check (ligado = false or channel_session_id is not null), e a ausência é deliberada: on delete set null é um UPDATE, o CHECK seria reavaliado e VIOLARIA com ligado=true, abortando o DELETE INTEIRO da conexão — a rota de exclusão de canal devolveria 500 com mensagem de constraint, sem pista de que a causa está em outra tela. A coerência é do trigger trg_aviso_de_caso_coerente, que se autocura (canal nulo ⇒ ligado cai para false). Escrita da configuração só por fn_definir_aviso_de_caso (security definer, quatro guardas na mesma transação: admin, escrita de suporte liberada, MFA comprovada quando há fator, e o canal sendo da própria organização), que ainda recusa o número da PRÓPRIA organização (o laço robô-com-robô) e, sem p_confirma_contato, o número que já é de um CLIENTE — porque configurá-lo faz as mensagens daquela pessoa pararem de chegar ao CRM. O casamento usa as DUAS grafias do nono dígito, a mesma regra de lib/channels/phone-variants.ts. Mais duas funções só para o servidor: fn_registrar_jid_do_aviso (o handler grava o JID que o transporte resolveu — é o que faz o corte da ingestão valer para destinatário em modo privacidade) e fn_contar_mensagem_ignorada (os contadores do descarte). Nenhuma das duas toca updated_at, que responde 'alguém MEXEU na configuração': uma resposta do suporte não é alguém mexendo na configuração. Leitura por RLS for select: configuração é admin (quem configura conexão é admin), histórico é manager ('o aviso está saindo?' é pergunta de quem opera). Nenhuma policy for all ⇒ as duas nascem fora da consulta cmd='ALL' do gate de RBAC. fn_expurgar_avisos_de_caso_vencidos (180 dias, piso de 30 no corpo) entra no cron diário de retenção como sétima poda. Dois vocabulários crescem: agent_case_events.kind += alert_sent e agent_inbox_items.kind += aviso_de_caso_nao_entregue. Na CADEIA as duas constraints são reconstruídas com a lista INTEIRA (tests/unit/kind-check-migration-x-baseline.test.ts exige igualdade valor a valor com o baseline); no baseline.sql o valor entra no bloco ÚNICO já existente, porque um segundo bloco é o defeito da issue #159. A cascata de LGPD ganha o passo de entregas_de_aviso_de_caso (erro_detalhe = null) e o kind novo dentro do in (...) do passo de agent_inbox_items — corpo VIGENTE derivado por script, com as duas edições provadas reversíveis byte a byte. Ponto cego declarado: tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts só cobra tabela com FK para contacts E coluna de nome-de-PII; nenhuma das duas satisfaz, então o gate ficaria VERDE sem aquele passo. Ele entra porque é certo, e quem o vigia é a catraca tests/invariants/cascata-lgpd-nao-encolhe.test.ts. Para ver o vocabulário em vigor sem acreditar nesta linha: grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql. Número 0292 e não o 0280 do plano: 0279–0282, 0290 e 0291 estão tomados — medido sobre todas as refs com git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*' | grep -oE '_0[0-9]{3}_' | tr -d _ | sort -u | tail. |
| 20260918130000 | 0293_a_passagem_se_reconhece_sozinha | O aviso de passagem para humano se resolve sozinho quando alguém assume — e a passagem fica marcada como reconhecida. A 0291 criou passagens_de_atendimento.reconhecido_por/_em e NINGUÉM os escrevia; sem escritor, o cartão da conversa nunca sai de "esperando alguém assumir", o aviso da Central fica aberto para sempre e — o pior — como o aviso deduplica por episódio ABERTO, a PRÓXIMA passagem daquela conversa não abre aviso nenhum: o cliente pede um atendente de novo e ninguém é avisado. Além disso fn_expurgar_passagens_vencidas só apaga linha reconhecida (é o certo: passagem aberta é demanda viva), então a tabela nunca seria podada. Cria fn_passagem_reconhecida() + trg_passagem_reconhecida em conversation_assignment_events: UM gatilho cobre os cinco caminhos que trocam o dono de uma conversa (assumir, transferir, liberar, devolver, rodízio), porque todos passam por fn_conversation_assign, que insere a linha de auditoria na MESMA transação — e cobre o sexto que alguém escrever amanhã, sem tocar em rota nenhuma. SQL puro: nenhum HTTP dentro de trigger (anti-pattern nº 9). Ele traz uma SEGUNDA camada de guarda além do revoke insert que a 0279 já pôs na tabela de eventos: só reconhece quando conversations.assigned_to_user_id is not distinct from new.to_user_id, ou seja, quando a conversa REALMENTE está com aquele dono — uma linha de auditoria incoerente escrita com a service key marcaria como assumida uma passagem que ninguém assumiu, e o aviso sumiria da lista de quem precisa agir. to_user_id is null (release/devolução) NÃO marca: quem fecha esse episódio é fn_passagem_devolvida(uuid,uuid), também criada aqui, que grava reconhecido_em SEM reconhecido_por — o par que a 0291 documentou como "devolvida ao automático: ninguém assumiu, mas o episódio fechou" e que o CHECK passagens_reconhecimento_coerente permite só nesse sentido. Ela é security definer porque a policy da tabela é for select apenas (authenticated não tem update, de propósito: ninguém reescreve um fato) e porque abrir o client de serviço dentro de uma rota quando há molde de definer no repositório é privilégio a mais sem necessidade — a autorização mora NO CORPO, no padrão de fn_conversation_assign (auth.uid() is not null and not fn_role_at_least(org,'agent') ⇒ raise). As duas funções revogam de public, anon (as DUAS origens de EXECUTE) e a devolvida concede a authenticated, service_role. Chamada por lib/escalacao/retomada.ts, que serve a rota "devolver ao automático" E a tool MCP — um lugar só. Gate: tests/invariants/passagem-se-reconhece-sozinha.test.ts. Número 0293 e não o 0282 do plano: 0279–0283 e 0290–0292 estão tomados — medido sobre todas as refs com git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*' | grep -oE '_0[0-9]{3}_' | tr -d _ | sort -u | tail. |
| 20260918140000 | 0294_o_cliente_repetiu_depois_da_passagem | O laço de retorno da passagem vira número, e o cobrador ganha onde contar. A entrega de 0291/0293 pôs o contexto da passagem na frente de quem assume; faltava a medida que diz se aquilo serviu: depois de a IA passar a conversa, o cliente precisou repetir o que já tinha dito? fn_atrito_metrics ganha DUAS chaves em cliente — repeticao_pos_passagem (numerador: passagens em que o cliente repetiu) e passagens_medidas (denominador: passagens em que ele VOLTOU A FALAR). A razão NÃO é calculada no SQL: ela é montada em lib/metrics/atrito.ts, que é onde mora a regra ausência de dado é null, nunca 0 — publicar os dois números separados é o que permite distinguir "ninguém repetiu" de "ninguém voltou a falar", e o que torna a régua auditável por quem lê a tela. A régua é declarada e fixa: limiar p_repeticao_min (0.7, o MESMO do índice de repergunta — dois limiares para o mesmo fenômeno dariam dois números incomparáveis na mesma tela) e janela de 24 h depois da passagem. O instrumento já existia (fn_atrito_jaccard, 0135, immutable); nenhuma tabela de agregado nasce, porque número sincronizado por cron onde cabe uma consulta é o anti-pattern nº 5. ⚠️ fn_atrito_metrics é SECURITY INVOKER: um agent em visibility_mode='own' vê o número só das conversas dele, manager/admin veem o da organização — a ressalva viaja na nota da medida, junto do número. A segunda coisa é passagens_de_atendimento.cobrancas int not null default 0: o reconhecimento de 0293 só acontece por gesto de quem CHEGOU, e ninguém cobrava a passagem em que ninguém chegou (dos treze caminhos, UM nasce de caso — o case-stale-watcher não alcançava os outros doze nem por acidente; a população foi medida num CRM em produção com o mesmo desenho de fila: 22 pedidos parados, o mais antigo há 17,6 dias, onze deles gente pedindo para falar com uma pessoa). O segundo braço do MESMO cron reabre/eleva o aviso handoff da conversa e para no terceiro aviso, e é cobrancas que segura esse teto — sem contador o alarme nunca cala, e alarme que nunca cala treina a equipe a ignorar o alarme certo. Sem tabela nova, sem kind novo, sem evento novo. Corpo de fn_atrito_metrics DERIVADO do vigente por script, com as duas edições provadas reversíveis byte a byte. Gates: tests/invariants/atrito-repeticao-pos-passagem.test.ts, tests/unit/cobrador-de-passagem-nao-reconhecida.test.ts, tests/unit/atrito-par-eficiencia-dano.test.ts. Número 0294 e não o 0282 do plano: 0279–0283 e 0290–0293 estão tomados — medido sobre todas as refs com git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*' | grep -oE '_0[0-9]{3}_' | tr -d _ | sort -u | tail. |
| 20260919154000 | 0333_transporte_smtp_da_instalacao | O SMTP entra como OPÇÃO de e-mail, e a Resend continua funcionando (PR #714, @betoarts, recorte). platform_smtp_settings: singleton de escopo de INSTALAÇÃO (host, porta, política TLS/STARTTLS, usuário, senha cifrada, remetente e nome), no mesmo desenho de platform_meta_app (0257) e platform_google_oauth (0201) — RLS ligada SEM policies, revoke all … from anon, authenticated (obrigatório: o alter default privileges do topo do baseline concede tabela nova aos dois) e grant select, insert, update só ao service_role, atrás do gate de /admin/email. Senha cifrada por fn_encrypt_oauth; nunca volta ao browser — a tela recebe só um booleano dizendo se existe. O que ESTA migration não faz: não derruba nada da Resend. O PR original secava o caminho antigo (o lib/email/resend.ts virava um shim de 5 linhas, o pacote saía do package.json, as chaves saíam do Zod e uma migration irmã dava drop table na configuração anterior); o dono do produto decidiu o contrário — os dois caminhos convivem e quem tem Resend não mexe em nada, então esse secamento ficou fora do recorte e nenhuma tabela é removida aqui. Quem escolhe o transporte em runtime é lib/email/roteador.ts (SMTP quando há SMTP configurado; Resend quando não há), vigiado por tests/unit/email-marca-e-remetente.test.ts. As sete SMTP_* de lib/env.ts seguem valendo como piso de rollback e como caminho de provisionar a VPS sem abrir interface; o BANCO prevalece (lib/email/config.ts). Sem coluna nova em tabela existente, sem backfill, sem dado tocado. Apêndice do baseline idempotente (INSTALL e UPDATE). |
| 20260918011500 | 0282_extensoes_perfil_v2 | O banco passa a conhecer o perfil declarativo v2 (ADR-0003). A validação do CATÁLOGO em fn_extensions_admit_catalog fixava permissions em ["navigation.tasks"] (0271:194) e recusava chave desconhecida na entrada (0271:184): sem esta migration, um catálogo v2 é recusado com extension_invalid_input e o contrato novo viveria só no TypeScript. Permissões passam por public.fn_extensions_permissoes_validas (conjunto fechado, não vazio, sem repetição — espelho de EXTENSION_PERMISSIONS em lib/extensions/capacidades.ts), e o catálogo aceita o metadado de loja (publisher_label, homepage, repository, tags, published_at), que não entra no manifesto: o pacote descreve o que faz, o catálogo revisado descreve de quem é. Acrescenta extension_permissions_changed, a recusa que a spec v1 prometia "quando o contrato admitir outra permissão" — sem ela, atualizar de 1.0 para 1.1 acrescentaria uma porta sem ninguém na organização rever a lista que a tela existe para mostrar; vale em fn_extensions_finish_install (kind update) e em fn_extensions_revert_install. As três funções são reescritas INTEIRAS com o corpo da 0271 preservado, e não por substituição de texto sobre prosrc em tempo de execução, que dependeria do estado de cada clone. Sem tabela, coluna ou dado novo. Apêndice idempotente derivado do próprio arquivo da migration. |
| 20260918230000 | 0311_webhook_do_numero_no_canal_oficial | Três colunas de DESFECHO do registro automático do webhook do canal oficial (channel_sessions.meta_webhook_override_uri / _erro / _em): a URL que ficou registrada na Meta, o motivo da última falha e quando foi a tentativa. Fecha o defeito silencioso da fatia F1 da #850 — conectar o canal oficial deixava o operador com um canal que ENVIA e não RECEBE até ele colar a URL de callback à mão no painel da Meta, e por número; quem não sabia disso não via erro em lugar nenhum. Por COLUNAS, e não no metadata jsonb da sessão: são três leitores (GET do canal, POST de conexão, rota de re-registro) e chave dentro de jsonb é contrato que o update do ingest apaga sem avisar. add column if not exists, sem índice (lida sempre pela chave primária), sem grant novo: a URL carrega o webhook_path_token, que já vive nesta tabela sob RLS por organização. A limpeza no arquivamento e a reaplicação na reconexão ficam para a F1b, em cima destas mesmas colunas. Baseline INSTALL/UPDATE idempotente. |
| 20260918220000 | 0308_espera_longa_dorme | A espera longa de um fluxo de follow-up passa a sobreviver ao contato mandar mensagem, que é o que faltava para uma cadência de retorno ("volte a falar daqui a 28 dias") caber num fluxo em vez de morar no prompt do agente. Duas coisas a matavam, as duas caladas: lib/followup/reactivity.ts ou CANCELA a inscrição parada num wait (cancel_on_reply) ou grava inbound_woke e corta o timer — e numa espera de 28 dias a cliente de manutenção fala com o estúdio várias vezes, sem que isso signifique antecipar nem desistir do retorno; e o índice único anti-spam trancaria o contato fora de qualquer outra cadência por um mês. Por isso a regra vivia no prompt chamando crm_schedule_followup (que grava em cron_jobs, é imune e não ocupa vaga), onde não dá para editar o prazo, ver quem está esperando nem medir o resultado. O status novo dormente é a projeção em runtime de wait.immune_to_reply (campo novo do nó, só no modo fixed, exigido ≥24h por immune_wait_too_short em validate-publish.ts) — não uma coluna imune, que seria segunda verdade sobre o mesmo fato e divergiria no primeiro republish; quem a escreve é o mesmo wake_status que já projeta waiting_reply (node-handlers.ts → engine.ts). É o status que faz a feature custar zero na reatividade: LIVE_STATUSES não o inclui, então a inscrição dormente nem é carregada — nenhuma query nova por mensagem recebida, nenhum grafo lido ali — com uma exceção deliberada, o ramo de STOP/opt-out, que alcança o dormente como alcança qualquer outro (LGPD não admite exceção de status). idx_followup_enrollments_one_live NÃO é tocado: ele enumera os status que ocupam vaga e dormente fica de fora por construção, então a vaga é liberada sem uma linha de DDL sobre o índice e o guard anti-empilhamento continua com a força de antes para os status que já cobria. dormente entra na perna com relógio do CHECK de coerência (é o next_eval_at que o acorda, pelo mesmo claim — não há segundo agendador), e nas duas listas de fn_claim_due_followup_enrollments mais no índice parcial do claim: é aí que esta migration falha calada se alguém a encurtar — sem isso a inscrição dorme e nunca acorda, sem nada reprovando. Os dois CHECKs saem pelo catálogo (pg_constraint), não pelo nome, porque em clone que passou por dump/restore o nome gerado diverge e o drop por nome fixo falharia em silêncio (mesmo cuidado da 0145). Re-aplicável: os CHECKs só ampliam o conjunto aceito, o predicado novo do índice cobre todas as linhas do antigo, e nenhum banco tem dormente antes desta migration — sem backfill e sem dado tocado. |
| 20260918001726 | 0276_rodada_do_banco | A rodada de atualização carrega o que aconteceu com o banco — disputa, retentativas e passada — e a tela de atualização conta isso quando termina. Três colunas em system_update_runs (disputa_de_banco, retentativas_do_banco, passada_do_banco) guardam, por rodada, se houve disputa de lock com o sistema no ar, quantas retentativas o kit gastou e em qual passada o baseline reaplicado fechou — a mecânica que o PR #997 mostrou (o baseline reaplicado sobrevive à disputa) e que até aqui só existia no log do servidor. Nulo é "não medido" (caminho que não passou pelo banco, como atualização só de código) e a tela não inventa texto para isso. A CHECK system_update_runs_rodada_do_banco_coerente recusa número incoerente — mais retentativas do que passadas. |
| 20260919153000 | 0325_protecao_de_tabela_de_organizacao_sai_do_laco | As proteções de tabela de organização saem do laço do baseline para funções SEM PARÂMETRO — o mecanismo que a ADR-0002 (D5) exige para que tabela criada DEPOIS do baseline nasça protegida. Irmã da 0274: lá foram as três restritivas do suporte, aqui são RLS ligada, revoke all … from anon e o isolamento por organização, em public.fn_proteger_tabelas_de_organizacao(), mais o ponto de entrada public.fn_proteger_modulo_provisionado() que uma provisionadora de módulo chama no fim do corpo (ela encadeia a rotina nova e depois a da 0274 — a ORDEM importa, porque as travas do suporte leem o privilégio de authenticated de cada tabela para decidir entre as três restritivas e o contrato server-only). A régua é not relrowsecurity, e não "toda tabela com organization_id" — medido, não suposto. Num pg17 descartável com o baseline de ed42ad119 aplicado com ON_ERROR_STOP=1, das 119 tabelas de organização: 0 sem RLS, 49 ainda com privilégio de anon, 8 server-only (RLS ligada e ZERO policies, de propósito: platform_support_sessions, extension_operations, channel_connection_requests, appointment_recovery_receipts, event_service_origins, ad_platform_connections, ad_insights_connections, ad_conversion_dispatches) e 66 sem a policy ampla. Varrer as 119 abriria as 8 a todo membro da organização e atropelaria as policies por papel das 66 — o próprio baseline avisa disso na linha de ai_routers. Já uma tabela recém-criada (o que a provisionadora produz) nasce com relrowsecurity=false, anon com SELECT e zero policies; nenhuma das 119 está nesse estado. Logo a varredura é no-op no baseline de hoje e exata para a tabela de módulo de amanhã — e um módulo que queira policy por papel ou server-only liga a RLS ele mesmo, e sai do alcance dela. Prova de que o comportamento do baseline não mudou: impressão digital do catálogo (RLS por tabela, as 491 policies com papéis/using/with check, ACL de tabela, ACL de função, colunas, índices, gatilhos) tirada antes e depois num pg17 descartável — 3522 → 3524 linhas, e as 2 de diferença são as duas funções novas; install(novo) == update(novo) == clone-do-baseline-antigo-atualizado, os três com diff vazio. As listas enumeradas do baseline continuam onde estão, de propósito: trocá-las pela varredura no MEIO do arquivo mudaria o resultado, porque a medição de "0 sem RLS" é do estado FINAL. Nenhuma das duas é security definer; revoke execute das duas origens (public, anon, authenticated, service_role), sem grant — só quem aplica o schema as chama, e a provisionadora security definer do módulo roda como o dono. Sem tabela, coluna ou dado novo. Invariante: tests/invariants/provisionadora-de-modulo.test.ts (as três regras de forma da D4, com controle positivo por provisionadora de mentira, já que o conjunto real ainda é vazio) e o molde tests/invariants/molde-de-provisionadora.ts, que um módulo novo herda com uma chamada. |
| 20260919120000 | 0321_recibo_de_idempotencia_em_curso | O recibo de idempotência ganha o estado "em curso" — e a corrida de duas requisições SIMULTÂNEAS com a mesma Idempotency-Key deixa de produzir o efeito duas vezes (issue #778). public.idempotency_keys.status_code e response_body deixam de ser not null: a linha passa a ter dois estados, e o check idempotency_keys_recibo_ou_reserva ((status_code is null) = (response_body is null)) diz qual é qual. RESERVA — os dois nulos, gravada ANTES do efeito por quem ganhou o índice único: é ela que faz o segundo pedido colidir com 23505 em vez de reexecutar, e expires_at curto enquanto reserva garante que processo que morreu no meio não tranca a chave (reserva vencida é de quem chegar depois; efeito que lança erro expira a própria reserva, e a retentativa executa). RECIBO — os dois preenchidos, estado terminal de 24h que já existia, e é ele que responde "esta chave já produziu efeito?" no replay. O check fecha o meio-termo (um gravado e o outro não), que nenhum leitor sabe interpretar e que só apareceria por bug de escrita. A escolha entre fechar no schema e fechar por RPC em cada rota está medida no PR da issue: o caminho reutilizável é o de efeito em CÓDIGO de aplicação, e código não cabe na transação do recibo — lá a única forma de serializar é a linha existir antes. Para quem já instalou, nada muda: toda linha existente tem as duas colunas preenchidas e passa no check sem backfill, a nulidade sai e o check entra na MESMA migration (entre uma e outra o banco teria um estado que o produto não usa), e RLS, policies e grants da tabela não são tocados — a travinha idempotency_platform_creation_server_only segue recusando o caminho privilegiado a anon/authenticated. Derivada no baseline.sql em dois lugares, e os dois são necessários: o create table do corpo já nasce anulável (install), e o bloco (migration 0321) do apêndice faz os dois alter column ... drop not null e a constraint — sem ele o update.sh de uma VPS já instalada passa pelo create table if not exists sem efeito, as colunas seguem not null e toda reserva falha com 23502 (medido em Postgres 17 na triagem do PR #1189). Gate da simultaneidade: lib/api/idempotency.test.ts e tests/invariants/idempotencia-reserva-antes-do-efeito.test.ts. Número: já foi 0278 (colidiu em timestamp com a 0310) e depois 0314 (colidiu em NNNN com o PR #1123) — renumerada duas vezes em 18/09, sem mudar o corpo. |
| 20260918210000 | 0306_google_ads_captura_de_clique | google_ads_landing_pages (WhatsApp + template por organização) e google_ads_click_refs (par token curto ↔ gclid, criado no clique e consumido quando a mensagem chega). Fecha a lacuna que lib/plataformas-de-anuncio/registry.ts (0213) já declarava: sem landing page não havia extrator de gclid para o Google Ads. Server-side only, mesmo desenho de ad_platform_connections — RLS ligada sem policies, grants de anon/authenticated revogados. Sem função nova em public. Recorte da frente de Google Ads do PR #965 (@automatikpg-ux), cuja frente de Instagram sai em PR próprio por decisão do dono do produto; renumerada de 0263 para 0306 porque 0263 já está ocupada na main (a numeração original do contribuidor era 0252, renumerada por ele para 0263 ao atualizar a branch). |
| 20260918211000 | 0307_google_ads_conversao | ad_platform_connections ganha google_refresh_token_encrypted, google_customer_id, google_login_customer_id, google_conversion_action_id. Fecha o transporte de conversão do Google Ads (lib/plataformas-de-anuncio/google/conversions.ts): refresh token OAuth em vez de access token longo-vivo (o da Meta dura meses, o do Google ~1h), mais os três identificadores de para onde reportar. Colunas nullable na MESMA tabela, sem CHECK cruzando platform — a completude por plataforma é de lib/plataformas-de-anuncio/credenciais.ts, não do schema. Recorte do PR #965; renumerada de 0265 para 0307 (0265 é buraco na main, e a doutrina é max+1, nunca preencher buraco). |
| 20260918231000 | 0312_aviso_de_followup_sem_agente | O fluxo de follow-up publicado que nunca vai disparar passa a aparecer na Central. Gatilho automático (silence, stage_change, case_opened, appointment_no_show) só cria inscrição se algum agente PUBLICADO tem o ponteiro em followup.flow_pointer_ids — regra em lib/followup/agent-followup-gate.ts, repetida dentro de fn_appointment_recover para a recuperação de falta. Sem esse vínculo os produtores saem por pointers_armados = 0 em silêncio: o fluxo fica active na tela, com versão publicada, e nenhum contato é reengajado — sem erro, sem log que alguém leia, sem linha em tabela nenhuma. É o invariante 6 do Sistema Vivo (o return mudo em cima de estado configurável), e ficou mais fácil de cair com a galeria de modelos (lib/followup/modelos/), que deixa instalar um fluxo em dois cliques sem passar pelo agente. Acrescenta o kind followup_sem_agente ao CHECK de agent_inbox_items (recriado INTEIRO, como a 0233 e a 0242 — add constraint não é idempotente) e o índice parcial agent_inbox_items_followup_sem_agente_aberto_idx (organization_id, ref_id) where kind='followup_sem_agente' and status='open', que o cron followup-sem-agente consulta duas vezes por rodada: para não abrir o segundo aviso e para FECHAR o aberto quando o vínculo aparece. ref_kind='followup_flow', ref_id = o id do ponteiro. Sem coluna nova, sem backfill: nenhuma linha existente usa o valor novo. |
| 20260919130000 | 0323_marcadores_do_contato_na_conversa | O filtro por marcador do Inbox casa a caixa da conversa OU a do contato. Campo calculado public.tags_do_contato(conversations) (SECURITY INVOKER, stable, revogado de public/anon) para o or=(tags.cs.{x},tags_do_contato.cs.{x}) do handler da lista; dispensa a lista de contact_id na URL, que tinha teto de ~186 ids. PR #1206 (@rafaelbatistazz). Apêndice no baseline antes da varredura de anon. |
| 20260919220000 | 0339_canal_mudo_sem_numero | O canal de WhatsApp em modo de teste sem número autorizado passa a ter aviso próprio na Central (doc 11, decisão B do dono). Acrescenta canal_mudo_sem_numero ao CHECK de agent_inbox_items.kind. O canal nasce mudo de propósito — ninguém recebe resposta automática por acidente durante a configuração —, e o defeito é o ESQUECIMENTO: as mensagens chegam no Inbox, tudo parece funcionar e a IA nunca responde, então quem instalou conclui que o produto está quebrado. Quem emite é o cron canal-mudo-watcher (diário) depois de 3 dias, e quem FECHA é a mesma rodada, quando o canal ganha número, sai do modo de teste ou é arquivado. A lista vem INTEIRA de propósito: add constraint substitui a constraint, então reconstruir com lista parcial apaga os outros kinds em silêncio — sem erro de SQL e sem conflito de git. Derivada do baseline.sql no momento do commit; a igualdade com o union InboxKind é vigiada por tests/invariants/vocabulario-banco-x-typescript.test.ts, e a lista que não pode encolher por tests/invariants/canal-mudo-kind-aceito-pelo-banco.test.ts. Aditiva: nada a corrigir antes. |
| 20260919150500 | 0326_reconciliacao_das_branches_represadas | O encontro de duas branches que reconstruíram os MESMOS objetos. Esta branch (casos vivos) ficou represada enquanto 0312 refez agent_inbox_items_kind_check sem aviso_de_caso_nao_entregue (da 0292) e 0322 redefiniu fn_atrito_metrics sem repeticao_pos_passagem/passagens_medidas (da 0294). Nenhum dos dois lados errou — cada um reconstruiu a partir do estado que via; o defeito é do ENCONTRO. Forward-fix porque editar 0312/0322 quebraria quem já as aplicou. Afirma a UNIÃO medida: CHECK com os 28 valores de 0312 + aviso_de_caso_nao_entregue (29); fn_atrito_metrics com o corpo de 0322 (incluindo envios_por_automacao, envios_por_integracao e a regra moved_to_another_pipeline da 0266) MAIS o bloco do laço de retorno da 0294. Sem a reconciliação, quem aplica a CADEIA fica sem o kind (todo aviso de caso não entregue recusado pelo CHECK) e sem a medida que diz se a passagem para humano serviu. Idempotente — desde a correção: a primeira versão prometia drop constraint if exists no cabeçalho e o SQL não o tinha; em banco que segue a cadeia o add falhava com 42710 (a constraint existe desde a 0050) e a aplicação parava aqui. Editada depois de entrar na main, por exceção consciente: forward-fix não serviria, porque a cadeia nunca chega nela, e editar é seguro, porque esta migration não pode ter rodado com sucesso em banco que segue a cadeia. O kit self-host não foi afetado (aplica o baseline.sql, onde o bloco sempre foi drop + add). Reprodução (pg17 + prelude do test-db.sh, ON_ERROR_STOP=1): sem o drop, rc=3 already exists; com o drop, rc=0 e reaplicada rc=0. NÃO MEDIDO: se alguma instância do mantenedor aplica a cadeia (MCP ou db push) e contornou a falha à mão — aí o que rodou lá difere deste arquivo. Cerca: tests/unit/reconstruir-constraint-derruba-antes.test.ts. |
| 20260919160000 | 0335_marcador_do_contato_normalizado | O marcador de contato já gravado passa a caixa baixa. A escrita passou a normalizar (corta as pontas, minúsculas, teto de 40 caracteres, sem repetição) nos quatro caminhos — ficha (NewContactDialog/EditContactDialog), importação por CSV (lib/contacts/csv.ts), API (contactCreateSchema/contactPatchSchema/contactListQuerySchema) e crm_manage_tags — pela MESMA função que o filtro usa para ler (lib/contacts/tag-normalizada.ts), o que faz ?tag=vip encontrar o contato marcado como "VIP". As duas sentenças update são o backfill do dado anterior: idempotentes por is distinct from (nenhuma linha tocada na segunda execução), ordem de primeira aparição preservada por with ordinality para a ficha não reembaralhar os marcadores, e teto de 40 igual ao do código. Issue #1224 (triagem do #1206). Apêndice no baseline antes das travas de suporte. |
| 20260919190000 | 0336_cor_das_etiquetas | A etiqueta ganha cor — e a cor que existia só na leitura passa a ter escritor, chip e filtro (issue #1271, fatia S6 da #852). fn_vocabulario_de_tags devolvia cor/descricao desde a 0264 e NADA escrevia nem pintava: superfície de leitura sem escritor. A operação ganha a ação definir_cor, com o parâmetro NOVO p_cor text default null — parâmetro novo porque p_destino é nome de etiqueta em toda a função, e hex por ali faria a validação de tag valer para cor; default null para as chamadas de quatro argumentos seguirem válidas, e drop function … (uuid, text, text, text) para as duas assinaturas não coexistirem (medido no PostgREST: com as duas no catálogo, a chamada de quatro chaves resolvia na ANTIGA e a cor nunca chegava ao banco — a tela dizia "atualizada" e o vocabulário não mudava). A ação sai ANTES dos laços, com return cedo: cor é atributo do VOCABULÁRIO (organizations.settings.tags), nunca das linhas — contacts.tags, crm_leads.tags e conversations.tags seguem text[] de nomes (contrato de automação, webhook lead.tag_added e MCP *.tags_changed, fatia S4) — e o bloco de canonical_conversation_tags, que troca o nome da semente pelo destino, APAGARIA a semente com destino nulo. Forma validada (^#[0-9a-f]{6}$ → cor_invalida, 22023) e papel (manager) antes de escrever; "sem cor" é null e limpa. Dar cor é curar: etiqueta que só existia em uso (ou como semente) ganha entrada no vocabulário curado — senão a tela ofereceria cor para uma linha que a leitura ainda marcaria como "em uso, fora do vocabulário". O desempate do dedupe é o MESMO da função de leitura ((cor is not null or descricao is not null) desc), senão uma entrada duplicada (string + objeto) apagaria a cor recém-escolhida na primeira execução; cor e descricao sobrevivem ao rename, que já mexia só em {tag}. Na borda: lib/schemas/tags.ts ganha a ação e o campo cor (normaliza caixa, aceita #abc → #aabbcc, recusa destino fora do rename e cor fora do definir_cor), a rota passa p_cor sempre e registra a cor no audit, e a leitura leve app/api/v1/tags/cores/route.ts (viewer, uma linha de organizations, sem MFA) alimenta o chip de quem atende — separada da leitura pesada, que conta uso nas três tabelas e exige manager. Na tela: components/tags/ChipDeEtiqueta.tsx nos oito lugares que renderizavam Badge neutro (lista de conversas, editores do Inbox, painel do CRM, lista e ficha de contatos, tabela e painel de Tags), cor no filtro de etiqueta do Inbox (chip no gatilho, ponto na opção), do funil e de Contatos, e a fileira de oito tons no painel de Tags, com prévia antes de salvar. A paleta (lib/tags/cor-da-etiqueta.ts) foi ESCOLHIDA POR BUSCA com as duas réguas de lib/branding/contraste.ts: pior par a olho nu 0,1195 (piso 0,10), pior sob dicromacia 0,0576 (piso 0,05) e pior contraste do texto 4,75 (WCAG 4,5) — escolher a olho dava dois vermelhos separáveis sob deuteranopia e iguais para todo mundo; o texto do chip sai de melhorFrenteSobre, nunca do gosto de quem escolhe a cor. Cada tom tem NOME ("Âmbar", "Roxo") porque cor não pode ser o único jeito de escolher. Gate: tests/invariants/tags-cor-de-etiqueta.test.ts (Postgres: persiste e volta pela leitura, hex inválido não escreve nada, viewer recusado, cor de uma organização não aparece na outra) — e tests/unit/tags-cor-de-etiqueta.test.ts cobre o contrato do schema, da paleta e do chip, com tests/unit/tags-vocabulario.test.ts atualizado para a assinatura de cinco argumentos. |
| 20260919235000 | 0340_modulo_instalado | Instalar um módulo opcional na INSTÂNCIA cria as tabelas dele na hora, e a atualização reaplica e falha alto — D3 e D6 da ADR-0002 (onda 2, issue #1114), sobre o molde da 0325. Tabela modulos_instalados (sem organization_id: o corte é por instalação, decisão do dono; RLS ligada, zero policy, anon/authenticated sem privilégio). fn_modulo_instalar(p_actor, p_operation, p_modulo) (security definer, só service_role): confere o ator como admin de plataforma, pega a MESMA trava das extensões (pg_advisory_xact_lock(255,1)), é idempotente pelo recibo em extension_operations (tipo novo module_install, recibo de plataforma), recusa módulo cuja fn_<modulo>_provisionar() não existe (extension_module_unknown) e recusa durante atualização do núcleo; chama a provisionadora, registra o módulo e emite pg_notify('pgrst','reload schema') — sem isto a tabela nova seria 404 na API até o próximo restart. A lista de módulos instaláveis é o conjunto de provisionadoras que EXISTEM; não há segunda lista para divergir. fn_reaplicar_modulos_instalados() roda cada provisionadora de novo; falha de um módulo marca suspenso com o motivo e NÃO relança — a marca se confirma sozinha porque o kit aplica sem transação única. fn_conferir_modulos_instalados(), num comando SEPARADO e último do baseline, levanta ERROR se há módulo suspenso, com texto que não casa com BASELINE_ERROS_BENIGNOS do kit (o motivo original fica na coluna, nunca na mensagem, porque um already exists ali seria engolido). As duas sem grant: só quem aplica o schema as chama. Instalação nova e todo banco de hoje: zero módulos, as duas chamadas são no-op. Invariantes: tests/invariants/modulo-instalado.test.ts (as funções) e tests/invariants/modulo-suspenso-o-kit-reporta.test.ts (o rodapé REAL do baseline aplicado com psql -q -f sem ON_ERROR_STOP, como o kit, filtrado pela expressão lida do _common.sh). |
| 20260919234000 | 0342_catalogo_deepseek | Semeia o catálogo da DeepSeek em ai_models e o preço em ai_pricing: deepseek-flash e deepseek-v4-pro, supports_tools = true. É o "próximo provedor" que a 0127 (vocabulário aberto de provider) existia para destravar — OpenAI-compatível, então entra pela mesma fábrica @ai-sdk/openai com base URL própria, sem SDK novo. Ids e preços verificados no provedor, não inventados: GET https://api.deepseek.com/models devolve os dois ids, e a doc oficial publica entrada (cache miss) $0,14/1M e saída $0,28/1M → 14 e 28 centavos por milhão nas duas tabelas. O preço de cache hit ($0,0028/1M = 0,28¢) não entra: a coluna input_price_per_million_cents é integer e guarda o preço cheio de entrada; o desconto de prefixo é aplicado na cobrança do provedor, não é preço de catálogo — fica documentado para quem for somar cache no orçamento. Sem is_default_for_provider: o padrão por provedor é curadoria dos três semeadores originais e o invariante catalogo-de-modelos espera exatamente um para anthropic/openai/google; a escolha recai no mais barato com ferramentas (escolherModeloDoProvedor), como na OpenRouter. Sem coluna nova, sem função, sem backfill de dado existente. Idempotente (on conflict do update), baseline INSTALL/UPDATE idempotente. Catracas: tests/unit/provedor-deepseek.test.ts e o par tests/unit/provedores-x-registry.test.ts. |
| 20260919160431 | 0343_agenda_dos_colegas | A agenda dos colegas vira OPÇÃO POR ORGANIZAÇÃO, LIGADA por padrão (issue #978). A decisão do mantenedor no fio da issue (16/09) NÃO foi uma guarda fixa: é settings.colegas_podem_mexer_na_agenda (booleano no topo do jsonb, chave própria e não settings.agenda) — fn_agenda_settings substitui o objeto settings.agenda inteiro e recusa chave que não conheça (p_config - 'atraso_de_confirmacao' - 'protecao_sem_resposta' <> '{}' → agenda_config_invalida), então a chave seria recusada por ele e apagada na primeira vez que um Gerente salvasse os prazos; é o mesmo caso que levou cliente_pela_agenda para settings.crm na 0262. Ausente = LIGADO, e isso é literal: a leitura é (settings->'colegas_podem_mexer_na_agenda') is distinct from 'false'::jsonb, sem backfill, sem default novo e sem linha reescrita — quem já instalou não vê mudança nenhuma. Ligada, tudo é como sempre foi (qualquer Atendente mexe na agenda de qualquer colega); desligada, o Atendente só mexe no compromisso de que é DONO, e Gerente/Administrador seguem mexendo em tudo. A mesma regra nos DOIS lugares: fn_appointment_change_core (definição em vigor copiada do baseline com UM bloco novo) recusa com appointment_do_colega/42501 a mudança de compromisso alheio, e a rota (app/api/v1/agenda/agendamentos/_handler.ts, exigeDonoDoCompromisso) avisa antes de tocar no banco — inclusive na criação, onde o responsável resolvido pode vir do default_owner_user_id do TIPO e não ser quem está pedindo. Para quem vale (declarado no PR): pessoa com auth.uid() e papel abaixo de manager na opção desligada; IA e integração NÃO são "um atendente" e não têm agenda própria (o que as governa continua sendo o papel do token, que a rota já cobra); canal remoto do Google (p_remote, service role, auth.uid() nulo) escreve o que o Google mandou. Porta de escrita: fn_definir_colegas_podem_mexer_na_agenda(uuid,boolean) — Gerente ou acima, suporte de escrita e MFA comprovado, conferidos no corpo pelo auth.uid() (o piso é manager e não admin da 0262: é regra da AGENDA, a mesma tela de fn_agenda_settings, e não reescreve dado nenhum), devolvendo {ligado,mudou}. Sem tabela, coluna, índice ou policy novos; o apêndice do baseline entra antes do bloco da varredura anon e repete os revoke das funções redefinidas. Gates: tests/api/agenda-cancelar-agenda-do-colega.test.ts (comportamento no handler que a rota e as ferramentas MCP chamam — as duas posições da opção nos papéis, e a recusa SEM escrita) e tests/unit/agenda-dos-colegas-e-opcao-da-org.test.ts (a matriz da régua, o dono ausente, os atores que não são gente e as duas leituras — TypeScript e banco). |
| 20260920000500 | 0344_atribuicao_de_anuncio_exige_organizacao | fn_estampar_atribuicao_de_anuncio passa a exigir a ORGANIZAÇÃO — escrita cross-tenant barrada no where (issue #1248). A função é security definer, executável só por service_role (revoke de public/anon/authenticated), e o único limite era o p_contact que o CHAMADOR mandava: where id = p_contact and source_metadata->>'ad_platform' is null, sem organization_id nenhum. O backend resolve o contato pelo canal (ingest de Meta, Google, site, waha), não pelo dono dele — então id vazado, replay de webhook com id trocado ou erro de resolução estampava o anúncio no contato de OUTRA organização: escrita cross-tenant por um caminho que a RLS não vê, porque roda como definer. A organização vira parâmetro OBRIGATÓRIO (p_org uuid, sem default — chamada sem ela não compila, que é o ponto) e o where casa organization_id = p_org: contato alheio casa zero linhas, silenciosamente, como o primeiro-toque (a função nunca levanta erro — quem estampa anúncio não pode derrubar o atendimento por causa de atribuição). A assinatura antiga de TRÊS argumentos é derrubada ANTES do create (drop function if exists … (uuid, text, jsonb)), pela mesma lição medida na 0336: com as duas no catálogo, a chamada de três chaves resolveria na ANTIGA e a organização nunca chegaria ao where — o defeito continuaria de pé com a função nova parecendo aplicada. comment on function atualizado, par revoke/grant realinhado para a assinatura de quatro argumentos (uuid, uuid, text, jsonb) e notify pgrst, 'reload schema'. Sem coluna nova, sem backfill, sem mudança de política para anon/authenticated (que nunca executaram isto). Nos chamadores, a organização que já estava em mãos passa a ser argumento: lib/leads/atribuicao-de-anuncio.ts (organização da plataforma de anúncio) e lib/leads/origem-do-site.ts (organização da entrada), com os testes de unidade dos dois passando a exigir p_org no payload da RPC. Invariante: tests/invariants/atribuicao-do-anuncio-nao-atravessa-organizacao.test.ts (Postgres: sonda de catálogo provando que só existe UMA assinatura, controle positivo de service_role estampando na própria organização, chamada com a organização ERRADA não escrevendo no contato alheio, e has_function_privilege seguindo t,f,f para service_role/anon/authenticated). |
| 20260920003000 | 0359_cascata_lgpd_alcanca_a_comanda | A cascata de anonimização passa a alcançar sales. O módulo financeiro (0350-0357) trouxe uma tabela com FK para contacts e três colunas de texto livre que nomeiam a pessoa de novo (notes, cancel_reason, reverse_reason) — fora da cascata, anonimizar devolvia SUCESSO com o texto legível, e o SLA de D+15 era marcado como cumprido sobre dado que não saiu. Forward-fix da função do NÚCLEO, não edição da 0351: o passo pertence à cascata. PRESERVA valor, status, datas e o VÍNCULO com o contato — a venda é registro financeiro, e desligá-la faria o relatório por cliente deixar de fechar com o faturamento do período; o contato apontado já é Cliente Anonimizado #N. sale_items fica de fora de propósito: description ali é o nome do SERVIÇO, congelado na inclusão, e apagá-lo destruiria o relatório por serviço sem tirar dado de pessoa nenhum. create or replace da função inteira porque ela percorre uma lista escrita à mão. Apêndice no baseline ANTES do bloco VARREDURA anon. Vigiado por tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts, que deriva o escopo do catálogo e lê o corpo REAL instalado. |
| 20260920000000 | 0341_configuracao_da_instalacao_pela_tela | A configuração da INSTALAÇÃO passa a caber na tela, generalizando o caminho que a marca (0155) e a credencial do Google (0201) já tinham aberto: public.platform_config, uma linha por variável, nomeada como a própria variável de ambiente (CHECK ^[A-Z][A-Z0-9_]{2,63}$, para que a correspondência banco ↔ .env seja literal e conferível por quem opera a VPS). Linhas e não colunas, por duas razões medidas: (1) escala — são 42 chaves candidatas (27 migráveis + 15 knobs) e, cifrada, cada credencial ocupa quatro campos, o que levaria a tabela a 150+ colunas e faria de cada chave nova um ALTER; (2) o tudo-ou-nada documentado no cabeçalho de lib/branding/instalacao.ts — coluna nova em singleton faz o PostgREST devolver 42703 para a LINHA INTEIRA quando o código é novo e o schema é velho, derrubando os outros campos junto. Numa tabela de linhas esse modo de falha não existe: chave que o banco ainda não tem é linha ausente, e linha ausente JÁ significa 'usa o .env'. A cifra é da APLICAÇÃO, não do banco, e a escolha não é estética. O repositório tem os dois padrões convivendo: fn_encrypt_oauth (0201/0257) cifra com pgp_sym_encrypt e guarda a chave em private.app_secrets; lib/crypto/aes_gcm.ts cifra AES-256-GCM e guarda a chave só no .env (AI_CRED_AES_KEY, nove consumidores hoje). O backup.sh do kit roda pg_dump sem filtrar schema, pela mesma conexão privilegiada que semeia a chave — se a semeadura alcança private.app_secrets, o dump alcança —, e o backup não leva o .env (só banco + sessões do WhatsApp). Logo, com a cifra do banco, um backup vazado entrega a chave e o cofre no mesmo arquivo: tolerável para um segredo do Google, inaceitável quando o cofre guarda todas as credenciais da instalação. Esta tabela guarda o envelope cru (ciphertext/iv/tag) e nenhuma função do banco sabe abri-lo. CHECK platform_config_forma_do_valor faz o XOR entre segredo e knob: sem ele, uma linha poderia ter valor em claro E envelope cifrado, e o resolvedor teria de escolher — que é como um segredo vaza em claro. last4 existe para a tela dizer QUAL chave está lá sem devolver o valor. semeado_do_env não é enfeite de proveniência: é o que impede o .env de desfazer escolha humana (regra portada de precisaSemear, 0155) — linha com false nunca é re-semeada, inclusive quando está vazia, que é o caso que mata o campo (a pessoa apaga para voltar ao padrão e o render seguinte reescreve o valor antigo). Apagar a LINHA é o 'voltar ao padrão', por isso delete entra no grant. RLS ligada com zero policies, de propósito e pela mesma razão de platform_branding/platform_settings: a linha não pertence a organização nenhuma, então não há predicado de tenant que a isole; ninguém alcança pela REST e quem lê é o service_role, do servidor. revoke all ... from anon, authenticated explícito porque o alter default privileges ... on tables to anon do baseline alcança toda tabela criada depois dele — isto é, todo apêndice novo. Sem organization_id, logo fora da varredura de fn_aplicar_travas_de_suporte() (0274), e o apêndice entra antes do bloco final do baseline, que tem de continuar sendo o último. Sem dado tocado, sem backfill: tabela nova e vazia, e o resolvedor degrada para o .env enquanto assim estiver. Regra pura e testável sem Postgres em lib/instalacao/config-resolve.ts (11 casos em config-resolve.test.ts). |
| 20260920120000 | 0364_a_poda_de_nonces_aceita_os_nomes_das_irmas | A poda de nonces de OAuth volta a apagar — e a ATUALIZAÇÃO recebe o conserto. O cron data-retention drena todas as podas pelo mesmo laço de lotes, mandando sempre { p_retencao_dias, p_limite } (app/api/v1/cron/data-retention/route.ts:163), e o PostgREST resolve sobrecarga pelo NOME do argumento, nunca pela posição: public.fn_expurgar_nonces_de_oauth, nascida na 0190 com (p_dias int, p_lote int default 500), recebia os mesmos dois int com outros nomes, e por isso não achava sobrecarga nenhuma — PGRST202 todos os dias, public.calendar_oauth_nonces (a tabela que impede reuso do state do login Google) crescendo para sempre, e, no mesmo bloco do cron, a varredura de anonimizações LGPD interrompidas (route.ts:271) inalcançada, porque roda depois das podas. O defeito é o nome: as outras SEIS funções que o mesmo laço chama já falam p_retencao_dias/p_limite, e esta era a sétima. Por que o drop: create or replace NÃO troca nome de parâmetro de entrada — o Postgres recusa com "cannot change name of input parameter", porque o nome faz parte da identidade da função para quem chama por nome. Sem drop function if exists public.fn_expurgar_nonces_de_oauth(int, int) ANTES do create, numa instalação que já existe o conserto não entra: é a mesma razão pela qual o trecho correspondente do supabase/baseline.sql também derruba antes de recriar (o update.sh reaplica o baseline inteiro, e é por ali que a instalação existente recebe o conserto). O drop leva os ACLs junto, então o revoke execute ... from public, anon, authenticated e o grant execute ... to service_role da 0192 são reaplicados — não é redundância com a 0192, é o que repõe o estado que ela deixou. Aditiva e sem dado tocado: não há insert, update nem alter table; muda o NOME dos parâmetros e nada mais. A assinatura (int, int) é a mesma, o piso de 1 dia em p_retencao_dias continua no corpo e o 500 que a 0190 declarava para o lote continua valendo no corpo (coalesce(p_limite, 500)), como nas irmãs. Os nonces vencidos enquanto o cron falhava são apagados pelo primeiro lote bem-sucedido. |
| 20260921020000 | 0367_interface_por_empresa | O menu lateral passa a ser escolhido pela EMPRESA, não só pelo vínculo (issue #1341). organizations ganha interface_settings jsonb (not null default '{"preset":"completa"}') com a MESMA forma da 0221 — {preset, destinos?} — um degrau acima: a organização escolhe o universo de portas da instalação e cada vínculo escolhe menos dentro dele. A leitura passa a resolver EMPRESA ∩ VÍNCULO ∩ papel, com as portas essenciais sempre de pé. Default completa para toda organização existente: quem não mexer em nada não vê diferença. É apresentação: não participa de RLS, policy nem autorização — o papel continua decidindo página, API e ação. |
| 20260921040000 | 0372_banco_externo_do_agente | Recorte do PR #1130, de @vgamkt: a organização passa a cadastrar um PostgreSQL de OUTRO sistema (segundo CRM, ERP) para o CRM consultar em tempo real. Tabela external_db_connections (tenant-aware, organization_id not null com cascade), a senha em três colunas bytea (password_encrypted/password_iv/password_tag) cifradas pelo APP com AES-256-GCM e AI_CRED_AES_KEY — o mesmo padrão de ai_provider_credentials (0023), e não fn_encrypt_oauth (0201), porque quem abre a conexão é o Node via pg: a chave tem de estar no processo, e cifrar no banco exigiria mandar a senha em claro só para decifrá-la depois. Nunca há coluna em claro. Por organização e não por instalação, pelo mesmo motivo de ad_platform_connections (0213): duas empresas na mesma VPS têm bancos externos diferentes, e um singleton faria o agente de uma responder com dado da outra. ssl_mode default require (remoto sem TLS manda credencial e dado em claro pela rede), CHECK do vocabulário disable|prefer|require|verify-ca|verify-full; enabled para pausar sem reapontar host/porta/credencial; unique (organization_id, label) porque dois cadastros de mesmo nome deixariam tela e agente ambíguos. RLS em duas policies, não uma: select para qualquer membro da organização (decisão do dono — saber que a fonte existe não é segredo) e for all exigindo fn_role_at_least(organization_id,'admin') para escrita, de modo que um viewer falando direto com o PostgREST não reconfigura a credencial. O segredo não sai do servidor: a view external_db_connections_safe (security_invoker, logo herda a RLS da base) omite as três colunas cifradas e é a única com grant select to authenticated; revoke all ... from anon na tabela e na view, porque o alter default privileges ... on tables to anon do baseline alcança toda tabela criada depois dele. Triggers fn_set_updated_at e fn_audit_log_row. Nenhuma função nova em public ⇒ o item 9 da doutrina de migrations não é acionado. O schema do banco externo NÃO é espelhado de propósito: ele muda com frequência e cópia de schema envelhece — a introspecção é ao vivo, em lib/external-db/introspeccao.ts. Numerada 0233 na branch de origem; realocada no recorte porque 0233/0234 já foram para outra coisa na main. Apêndice idempotente no baseline.sql ANTES da reaplicação de módulos e das varreduras finais (tabela de organização nova tem de passar pelas travas de suporte na PRIMEIRA aplicação). |
| 20260921040100 | 0373_limites_configuraveis_do_banco_externo | Os tetos de leitura do banco externo deixam de ser cravados no código e passam a ser propriedade da CONEXÃO. max_rows (1..5000, default 200 — o antigo LIMITE_MAX), max_filters (0..100, default 20) e max_response_bytes (4096..1048576, default 30000), as três integer not null com CHECK. Colunas tipadas e não um jsonb de configuração porque o limite pertence à conexão (cada fonte tem um processo), não à organização nem à instalação — e CHECK diz a mesma coisa que um JSON sem validar nada, com RLS e PostgREST herdando a proteção de graça. Defaults IGUAIS aos valores antigos: ninguém muda de comportamento sem pedir. max_rows governa a grade da tela e o teto da consulta; max_filters e max_response_bytes governam o que entra no contexto do modelo. A view _safe é recriada expondo os três (são configuração, não segredo) com os grants refeitos logo abaixo. Doutrina item 8 no apêndice do baseline: os três update de clamp rodam ANTES do add constraint, para que o update.sh de um clone onde alguém escreveu um teto fora da faixa direto no SQL conserte em vez de quebrar. Recorte do PR #1130, de @vgamkt; 0234 na branch dele, renumerada aqui. |
| 20260921060000 | 0375_campanhas | Campanhas (Sub-PRD 12 / Spec 12 / Spec 13): campaigns e campaign_recipients. Duas tabelas, e só. Nenhuma channel_send_protection (Spec 13 §4.1): proteção de envio por conexão já existe neste repo com tela — channel_knobs + channel_sessions.daily_message_limit, editados pela AntiBanSheet (título literal: "Proteção de envio") e respeitados por decidePacing com contador real em pacing_ledger; a tabela da spec daria duas janelas e dois tetos por conexão, e alguém teria de decidir qual ganha em cada caminho de envio (anti-pattern nº 2). A campanha ganha só o OVERRIDE dela (intervalo_segundos, janela_*_hora, teto_diario, teto_horario, todos nullable = herda do canal), sempre o mais restritivo dos dois. Sem campaign_templates, sem campaign_suppressions, sem message_mode/provider_template_* — decisão do dono (2026-09-18): MVP é texto livre por WAHA. base_legal sem default (Regra nº 1: default plausível de base legal pareceria configurado e não estaria) com lia_ref obrigatório em legitimate_interest — mesma régua do gate de LGPD da cadeia de envio, e do LIA-2026-01. FK composta (organization_id, channel_session_id) (padrão 0260/0262) com on delete restrict: apagar o número apagaria para quem a campanha falou. Dedup em DUAS unicidades — por contato e por endereço —, porque dois cadastros gêmeos com o mesmo telefone são uma pessoa só. Trigger trg_messages_sincroniza_campanha (AFTER UPDATE OF status em messages) leva o ack ao destinatário: não há hook de aplicação para mudança de status (trg_messages_emit_event é AFTER INSERT — é por isso que o recover-stuck-messages emite message.failed à mão), e a alternativa era polling. Status analítico nunca retrocede: read não volta a delivered, replied não volta a nada. RLS no padrão da 0261 (SELECT ao tenant, escrita a partir de manager — rbac-config-ia-canais.test.ts proíbe policy ALL só-tenancy em tabela nova). |
| 20260921060100 | 0376_campanhas_templates_e_exclusoes | Templates e lista de exclusão de campanha (Spec 12 §2.3 e §2.4), pedidos na tela depois de a 0375 subir. campaign_templates guarda a copy FORA da execução — hoje o texto vive dentro de uma campanha, e quem tem uma abordagem que funciona copia e cola até a versão boa e a velha ficarem indistinguíveis; editar um template amanhã não reescreve o que alguém recebeu ontem, porque o conteúdo é congelado por destinatário com content_version. campaign_suppressions é a exclusão da OPERAÇÃO ("não mande campanha para este número"), deliberadamente diferente do opt-out, que é do TITULAR e vive em contacts.is_blocked — a suppression não mexe no cadastro nem no que o agente responde a quem escreve. Guarda hash do telefone (SHA-256 do E.164) mais os últimos dígitos para a tela dizer de quem é a linha: lista de "não mandar" não precisa virar um segundo lugar onde telefone de gente mora, e por isso ela fica fora da cascata de anonimização (não há PII a redigir; contact_id sai sozinho pelo on delete set null). RLS no padrão da 0375: SELECT ao tenant, escrita a partir de manager. Unicidade por (organization_id, name) nos templates e por (organization_id, recipient_address_hash) nas exclusões. |
| 20260921060200 | 0377_campanha_rodizio_de_numeros | A campanha passa a falar por VÁRIOS números. campaign_channel_sessions (tabela de vínculo, não array: com array nenhuma FK impede o número de OUTRA organização entrar na lista, e a checagem viraria código que alguém esquece — com linha, a FK composta (organization_id, channel_session_id) recusa no banco, padrão da 0260/0262/0375) e campaign_recipients.channel_session_id nullable, preenchida NO ENVIO. Aditiva: campaigns.channel_session_id continua obrigatório e é o número principal, então toda campanha existente segue funcionando sem uma linha sequer na tabela nova, e quem não quer rodízio nunca abre essa parte da tela. A escolha é dinâmica (decisão do dono, 2026-09-19): a cada envio ganha o número com mais folga no teto do dia, exceto quando o contato já conversa com um dos números do pool — aí vence o histórico, que é a mesma regra que a cobrança do Asaas usa, porque receber de um número desconhecido separa a mensagem do histórico da pessoa. Regra pura em lib/campanhas/rodizio.ts, com teto desconhecido tratado como MENOS preferido (tratá-lo como infinito concentraria no número sem configuração o volume que o rodízio existe para espalhar). Número fora de WORKING não entra no rodízio. O ritmo da CAMPANHA (intervalo, tetos) continua somando todos os números — o rodízio só aumenta volume quando a campanha herda o ritmo de cada número. |
| 20260921060300 | 0378_campanha_funil_e_agente | A campanha declara onde o card nasce e quem atende. Três colunas nullable em campaigns (pipeline_id, stage_id, agent_id) com FK COMPOSTA (organization_id, …) e on delete set null, mais campaigns_etapa_exige_funil (etapa sem funil seria card sem coluna) e o índice idx_campaign_recipients_conversa, que o degrau novo do roteamento consulta a cada turno. Funil: hoje quem decide é o NÚMERO (0262); a campanha VENCE quando declara — decisão do dono (2026-09-19), porque é a escolha mais específica e quem montou a campanha sabe onde quer medir. Sem declarar, nada muda para ninguém. Agente: paga uma dívida que o repo já tinha DECLARADO por escrito em lib/ai/elegibilidade/campanha.ts ("encaminhar por campanha exige levar o agent_id e o resolve-turn-agent respeitá-lo — não feito nesta entrega"), onde o campo já existia e era ignorado. resolveTurnAgent ganha um degrau ACIMA do roteador: o roteador é do número e classifica assunto, a campanha sabe que a pessoa está respondendo a uma abordagem. Só assume conversa que NASCE da campanha — cliente antigo que responde a uma reativação segue com quem já o atendia. Falha ABERTA: agente sem versão publicada cai na régua do número em vez de calar o atendimento, e isso é medido por sabotagem no teste. O card também nasce marcado (crm_leads.source='campanha' + id e nome no metadata), vencendo a origem de anúncio — o anúncio trouxe o contato meses atrás, mas este negócio nasceu porque nós falamos com ele. |
| 20260921060400 | 0379_campanha_na_cascata_lgpd | A campanha entra na cascata de anonimização. campaign_recipients guarda a MENSAGEM dita à pessoa (rendered_body) e o telefone (recipient_address); campaign_suppressions guarda a cauda do número. Nenhum dos dois estava em fn_lgpd_cascade_redact_contact — anonimizar devolvia SUCESSO e a prospecção continuava legível, falha muda com o SLA marcado como cumprido (medido pelo invariante lgpd-cascata-alcanca-quem-guarda-pessoa). Duas decisões deliberadas: a LINHA de campaign_recipients fica (é a prova de que a pessoa esteve na campanha; apagá-la desfaria a contagem de quem recebeu) e o HASH de campaign_suppressions fica (é ele que faz o "não me mande mais" continuar valendo depois da anonimização — apagá-lo faria a pessoa voltar a receber). Redefine a função inteira a partir da definição VIGENTE no baseline, como a 0235. |
| 20260921080000 | 0381_meta_ads_captura_de_utm | O ref curto que carrega as UTMs da landing page até a conversa do WhatsApp — a captura de clique da 0306 espelhada para o caso que faltava. Das três fontes que chegam ao WhatsApp, duas já rastreiam sozinhas (o referral nativo do clique-para-WhatsApp e o POST do formulário, que lib/webhooks/inbound.ts aceita com qualquer chave utm_*); sobra a página com BOTÃO de WhatsApp, e o motivo é físico: o link wa.me não fala com o CRM — abre o aplicativo no aparelho da pessoa —, então a origem tem de viajar dentro do TEXTO da mensagem. O contrato [dk1:<base64url>] (lib/leads/origem-do-site.ts) já fazia isso e continua valendo, sem depreciação; o que muda são os dois custos de uso dele: cerca de 200 caracteres de base64 visíveis para o lead, e o script que quem monta a página precisaria colar (o marcador tem de ser gerado POR VISITANTE, e link estático não gera nada). Duas tabelas, espelhando a 0306: meta_ads_landing_pages (para qual WhatsApp e com qual texto a rota redireciona, com check exigindo o literal {token} no template) e meta_ads_click_refs (o par ref↔UTM, único em (organization_id, token), com check de utm não vazio porque ref que não aponta para origem nenhuma só sujaria a mensagem do lead). utm é jsonb com as MESMAS dez chaves de CHAVES_DE_UTM, já normalizadas pela mesma função que o [dk1:] usa — sem vocabulário novo. Duas tabelas e não uma com coluna de plataforma, pela razão do cabeçalho da 0306: cada eixo de captura nasce e funciona sozinho, e mexer no par do Google para acomodar o da Meta faria uma landing page em produção depender de uma troca de chave primária que ela não pediu. Server-side only nas duas: RLS ligada, ZERO policies, revoke all de anon/authenticated, grant só para service_role — a UTM diz quanto e onde a organização gasta, e quem lê é o servidor com o admin client filtrando organization_id à mão. Sem cron de limpeza, igual à irmã do Google: linha nunca casada fica, e isso é dívida declarada, não omissão. Apêndice idempotente no baseline.sql logo depois do bloco da 0306, antes da VARREDURA anon. Gates: tests/invariants/captura-de-clique-e-server-side.test.ts (o describe.each da 0306 passa a medir as quatro tabelas) e tests/unit/meta-ads-captura-de-utm.test.ts (o gerador de ref e os três caminhos ruins da rota pública, todos devolvendo o WhatsApp). |
| 20260921070000 | 0380_hierarquia_do_anuncio | O anúncio tem nome, e o CRM só tinha o número dele. ad_hierarchy_cache guarda nome do anúncio, do conjunto e da campanha por (organization_id, platform, ad_id). O contato que chega por clique-para-WhatsApp traz só o id do anúncio — a plataforma não manda nome nenhum no referral —, e os nomes existem apenas na API, se alguém perguntar. O cache não é otimização prematura, é a condição para a feature durar um dia: a conta sondada responde ads_api_access_tier: "development_access" (medido, registrado no cabeçalho de lib/plataformas-de-anuncio/meta/insights.ts), e um anúncio que presta gera centenas de contatos — sem cache, cada ficha aberta repete a MESMA pergunta sobre o MESMO anúncio e a cota acaba no primeiro dia, com o operador vendo a ficha sem nome de campanha, igual a antes da feature existir. fetched_at guarda quando se perguntou; a idade aceitável é decisão do código que lê, não do schema — ela muda com o degrau de cota da conta, não com a forma do dado. organization_id entra no índice único porque o id do anúncio é da plataforma e duas organizações podem alcançar a MESMA conta (uma agência e o cliente dela): sem ele, a segunda a resolver leria a linha da primeira e mostraria, na ficha do contato dela, o nome que o vizinho deu ao anúncio — a mesma lição da #236. Sem FK nos identificadores: o anúncio é da plataforma e pode ser apagado lá sem aviso. Quarta tabela do eixo com o desenho server-side-only de ad_platform_connections (0213), ad_insights_connections (0214) e as duas de captura do Google (0306): RLS ligada, ZERO policies, grants de anon/authenticated revogados — nome de campanha e de criativo é a ESTRATÉGIA de mídia de quem anuncia, que um concorrente pagaria para ler, e a anon key vai para o browser. Deny-all é mais restritivo que policy de tenant, não menos; quem lê é o servidor com o admin client filtrando organization_id à mão. Nenhuma função nova em public. Gate: tests/invariants/credencial-de-anuncios-e-server-side.test.ts, que mede privilégio E comportamento (permission denied sob set role).
| 20260922021548 | 0382_link_salvo_no_modelo | O link da mídia fica salvo no modelo. Modelo com cabeçalho de imagem, vídeo ou documento exige o link em TODO disparo (a plataforma guarda só a amostra da aprovação), e o painel da janela fechada fazia o operador colar a mesma URL a cada vez. meta_templates.saved_values (jsonb not null default '{}', CHECK de objeto) guarda os valores a reaproveitar, chaveados como template_values no envio (slotKey: header:1…), então o painel pré-preenche sem tradução. Coluna no espelho e não tabela nova: o valor pertence à definição (organização + WABA + nome + idioma), que é exatamente a linha; syncTemplates faz upsert listando só as colunas da plataforma, então sincronizar não apaga o link. Só link de mídia: a rota de escrita (PATCH /api/v1/channels/templates, admin + requireSupportWrite) recusa chave que não seja slot de mídia e valor que não comece com https:// — {{1}} do corpo costuma ser o nome do cliente, e salvá-lo mandaria o nome de uma pessoa para a próxima. Aditiva, sem backfill, sem função, sem policy nova (a tabela segue escrita só pelo servidor). Apêndice no baseline antes da varredura anon, idempotente (add column if not exists, drop constraint if exists + add). |
| 20260922140000 | 0383_pedidos_de_cadastro | Cadastro com aprovação: a empresa nova espera o dono da instalação (recorte do PR #714, de @betoarts; decisão do dono, doc 24, Decisão 2 = (d)). platform_settings.signup_mode ganha o terceiro valor com_aprovacao (CHECK recriada com drop constraint if exists + add constraint, superconjunto da anterior; o default continua aberto, então nenhuma instalação muda ao atualizar). Tabela registration_requests (user_id, requested_organization_name, status pending/approved/rejected, decided_by, decided_at): o pedido de quem confirmou a conta pelo fluxo normal do provedor e quer abrir empresa numa instalação com_aprovacao; a empresa só nasce quando o administrador da instalação aprova em /admin/cadastro. Índice único parcial: um pedido pendente por conta. Da INSTALAÇÃO, não do tenant — o pedido é anterior à organização, então não há organization_id que o isole: RLS ligada, ZERO policies, revoke all de anon/authenticated, só service_role, o mesmo desenho de platform_settings (0253). Fora do desenho do autor, por bloqueador de segurança publicado no #714: pedido para ENTRAR em empresa existente (exigia listar as empresas a visitante anônimo — quem entra numa empresa existente entra pelo convite) e qualquer confirmação de e-mail sem prova de posse. Nenhuma função nova em public. Na branch do autor era 20260912115014_solicitacoes_de_cadastro, sem número. |
| 20260922190000 | 0384_banco_externo_vira_modulo_opcional | O banco de dados externo vira MÓDULO OPCIONAL da instalação, desligado por padrão (doc 37; completa o #1372). A chave é a linha MODULO_BANCO_EXTERNO de platform_config (0341), ligada e desligada por quem administra o servidor em /admin/sistema; sem linha = desligado (lib/instalacao/modulos.ts). Desligado: nenhuma porta no menu, /app/integracao-dados e /api/v1/external-db/* respondem 404, abrirAcesso recusa, e as ferramentas de IA do módulo não são oferecidas ao agente. Dado tocado: grava a linha SEMPRE na primeira aplicação — ligado se a instalação já tinha conexão cadastrada, desligado se não (case when exists ... on conflict do nothing). A atualização não tira a função calada de quem usava, e a reaplicação do baseline a cada update.sh nunca reescreve a linha: sem ela, uma conexão escrita depois pelo admin de uma empresa (PostgREST, com o módulo desligado) ligaria o módulo para a instalação inteira na atualização seguinte. Não é coluna de platform_settings porque criar a linha daquele singleton faria signup_mode nascer 'aberto' e vencer o SIGNUP_MODE do .env. |
| 20260923120000 | 0385_memoria_da_org_aceita_origem_agente | A memória da organização aceita a origem agent (diagnóstico de @vgamkt, #1130). A ferramenta MCP crm_save_org_memory grava org_memory_entries.source = 'agent', mas o CHECK inline da 0067 (org_memory_entries_source_check) só aceitava manual e flywheel: toda chamada falhava com 23514. O CHECK passa a aceitar ('manual','flywheel','agent') — ampliar, e não regravar como manual, porque a origem é a trilha de quem escreveu a política da empresa (decisão do titular). Dado tocado: nenhum; as linhas existentes cabem no conjunto novo. Idempotente (drop if exists + add); bloco único no apêndice do baseline. |
| 20260923120100 | 0386_preco_catalogo_e_conta_openai | As duas tabelas de preço do OpenAI passam a dizer o que a fonte mediu, juntas. O pricing.ts — que grava llm_calls.cost_cents e é o que a tela Uso e orçamento soma — cobrava gpt-5.6-sol a 400/2000 (preço PROMOCIONAL medido na fonte oficial em 2026-09-23, developers.openai.com/api/docs/pricing, faixa Standard; a própria página declara a promoção válida ao menos até 21/11/2026), enquanto ai_models e ai_pricing seguiam com 500/3000, a versão não promocional do catálogo 0101: a tela anunciava um preço, a conta somava outro e os dois divergiam do que a API cobra. As DUAS tabelas mudam no mesmo comando de propósito — o invariante catalogo-de-modelos ("o preço da tela é o MESMO que a conta usa") reprova qualquer lado que ande sozinho, e a notes mantém o prefixo catálogo que o invariante de procedência exige (preço de apêndice, não de cura automática do backfill 0113). Entram os três ids OpenAI que o código já cobrava e a tabela não tinha (gpt-4o, gpt-4o-mini, gpt-4o-2024-05-13) — sem linha, computeCost() cai no catálogo/backfill e o número pode não ser o do código, o mesmo buraco da #1478 por outro caminho. notes grava FONTE e DATA da medição porque preço promocional muda. Apêndice idempotente no baseline.sql logo depois do bloco da 0104. Gates: tests/unit/preco-openai-codigo-e-tabela-concordam.test.ts (compara as DUAS fontes — código × tabela — nos 12 ids, com sabotagem medida) e o invariante catalogo-de-modelos no CI. |
| 20260922164700 | 0387_canal_datafy | Canal de WhatsApp Datafy — parceiro homologado pela Meta que espelha a Cloud API — como canal OPCIONAL DA INSTALAÇÃO, desligado por padrão (decisão do dono, doc 54, opção b). Recorte do PR #1130, de @vgamkt (0235 / 20260913150000 na branch dele, renumerada aqui em número e timestamp). channel_sessions ganha datafy_phone_number_id (o sessionRef do canal, coluna PRÓPRIA e não a do canal oficial, porque os dois provedores podem conviver na mesma instalação e endereçam servidores diferentes), datafy_waba_id e datafy_token_encrypted (cifrado por fn_encrypt_oauth, como a chave do outro parceiro). Três CHECKs recriados por alargamento puro — channel_sessions_provider_check e channel_sessions_provider_ref_check com o vocábulo datafy, e webhook_events_log_provider_check (0151), sem o qual a rota genérica de canal não conseguiria arquivar o corpo cru do que o provedor manda. Índice único parcial entre ativos para datafy_phone_number_id (desenho da 0165, issue #236), com dedup -conflito-<id> ANTES dele. O desligamento é do CÓDIGO (DATAFY_ENABLED), não do schema: as colunas nascem nullable e vazias em toda instalação. Sem tabela nova (a tabela tocada já tem RLS por organização), sem função nova em public. No baseline, colunas e vocabulário nos blocos ÚNICOS das constraints; o apêndice próprio só traz a dedup e o índice. |
| 20260923160000 | 0392_demanda_derivada_nao_reduplica | O update.sh do clone duplicava a demanda que já existia. O backfill histórico da 0136 (R2: toda conversa que nunca escalou vira demanda origem='derivada') vive no apêndice do baseline.sql, que o kit re-aplica INTEIRO a cada atualização. O guard de R2 só procurava outra 'derivada' com o mesmo aberta_em e não enxergava a demanda 'inbound' que o trigger da 0138 abre na entrada. Medido em pg17 com os blocos do baseline da main de 23/09: inbound=1 depois do install + 1 mensagem, inbound=1 derivada=1 depois do update. Sintoma mudo: o Radar publica o dobro de demandas abertas sem próximo passo e fn_atrito_metrics devolve escopo.demandas dobrado. Dois artefatos: o guard novo (not exists em demanda_conversas para a conversa) mora no apêndice do baseline, onde R2 re-executa; esta migration faz só a AUTO-CURA, e o mesmo delete está no apêndice para o clone do kit. A cura só apaga a 'derivada' intocada (sem próximo passo, lead, dono humano ou caso), ligada a uma conversa só e nascida DEPOIS de outra demanda de origem real naquela conversa — a derivada legítima do backfill é anterior ao trigger e fica, inclusive numa conversa reaberta que ganhou uma 'inbound' depois. E não apaga a duplicata que já é referência (conversations.current_demanda_id, messages.demanda_id, lead_checkpoints.demanda_id): o passo 2 do backfill da 0222 escolhe a vigente pelo maior aberta_em, e a 'inbound' carrega o sent_at do WAHA (anterior ao insert da conversa), então a duplicata costuma virar a vigente; apagá-la zeraria as referências por on delete set null, abriria outra demanda na próxima entrada e cancelaria como vencido o acompanhamento com fronteira nela. Como essas colunas nascem no bloco da 0222, mais abaixo no apêndice, a cura no baseline roda dentro de um do que só age quando as três existem (no install ainda não existem, e não há demanda para curar). Guardado por tests/invariants/atualizacao-nao-reduplica-demanda.test.ts, que EXECUTA o bloco extraído do baseline. Retoma o trabalho de agosto que usava o número 0142, hoje ocupado. |
| 20260923150000 | 0391_anonimizar_pela_tela_redige_conversas | Anonimizar o contato pela TELA passa a redigir mensagens, conversa e o resumo do agente (achado da prova prática do PR #1130). Havia dois caminhos de anonimização com cobertura diferente: o pedido formal (fn_lgpd_cascade_redact_contact) redigia messages e conversations; o botão da ficha (fn_lgpd_anonymize_contact + lib/lgpd/cascata.ts) só reescrevia o contato, leads, atividades e a régua — o nome e o CPF que a pessoa escreveu ficavam no corpo das mensagens, no last_message_preview (cabeçalho da ficha anonimizada) e na mídia no bucket. lead_checkpoints (resumo corrido, compromissos, objeções, próxima ação e declaração do turno — texto de modelo que nomeia a pessoa) não estava em NENHUM dos dois. Conserto no ESTADO: gatilho trg_redigir_conversas_ao_anonimizar (after update of is_anonymized, só na virada false→true), o mesmo desenho dos outros gatilhos de redação do schema; os comandos de mensagens/conversas são os MESMOS da cascata formal, que os repete sem efeito novo. A mídia entra em storage_redaction_queue ANTES de a coluna ser zerada (request_id nulo; fila idempotente por (bucket, object_path)). Dado tocado: cura de contatos JÁ anonimizados — redige mensagens, conversas e checkpoints deles e enfileira a mídia; cada comando só alcança linha com resíduo, então reaplicar não reescreve nada. Função nova security definer em public com as DUAS origens de EXECUTE revogadas (public e anon, e authenticated). Gate: tests/invariants/lgpd-anonimizar-pela-tela-redige-conversas.test.ts (os dois caminhos, controle positivo e o vizinho intocado). |
| 20260923210000 | 0394_fluxos_de_atendimento_base | A base dos fluxos de atendimento (roteiro de perguntas que a IA conduz no turno), de @vgamkt (#1130), como MÓDULO OPCIONAL desligado por padrão (doc 64, opção a). A chave é MODULO_FLUXOS_DE_ATENDIMENTO em platform_config (0341) — sem migration para ela. Das oito migrations do autor (0236–0243 na branch dele) nenhuma tabela sobra, pela DIRC: a resposta do cliente vai para contacts.custom_fields (já exportada ao titular e já zerada pela anonimização nos dois caminhos, trg_contacts_anonimizado_limpa_custom_fields); a trilha vai para followup_enrollment_events SEM o valor; as tentativas são contadas da trilha; a síntese é montada dos campos (sem completion_note, sem job flow_summary). Schema: followup_flow_pointers.surface aceita atendimento e followup_enrollments.status ganha coletando (blocos únicos da 0196 e da 0145 editados no lugar; o laço da 0145 passa a derrubar pelo catálogo a versão sem coletando); coletando fica no grupo sem relógio e FORA de idx_followup_enrollments_one_live — na prova prática, o roteiro parado ocupava a vaga do follow-up e quem sumia no meio nunca recebia a retomada; índice único próprio idx_followup_enrollments_um_roteiro_coletando (um roteiro vivo por contato); ai_router_members.flow_pointer_id com FK COMPOSTA (organization_id, flow_pointer_id) → followup_flow_pointers(organization_id, id) on delete set null (flow_pointer_id) (pg15+, o piso do kit), com o índice único (organization_id, id) que ela exige; gatilho trg_contato_anonimizado_encerra_roteiro (virada false→true de is_anonymized) cancela o coletando do contato nos dois caminhos de anonimização; gatilho trg_enrollment_superficie_coerente (BEFORE INSERT/UPDATE de status/pointer_id) recusa com 23514 enrollment de roteiro fora de coletando/terminal e coletando fora de roteiro — os produtores do relógio escolhem pointer por trigger_config, não por superfície. Revisão adversarial: a superfície fica IMUTÁVEL (trg_superficie_do_fluxo_imutavel, 23514) e roteiro só tem gatilho manual (CHECK followup_flow_pointers_roteiro_so_manual) — sem isso um viewer mudava pelo PostgREST a superfície de um fluxo de silêncio e a varredura abortava a cada tick; e o bloco da fusão por nono dígito (0198) no baseline, que roda a cada update.sh, passa a encerrar o roteiro vivo excedente (fica o mais novo, evento roteiro_cancelado) ANTES de reapontar followup_enrollments, senão o índice novo daria 23505. Funções novas com as origens de EXECUTE revogadas. Sem dado reescrito. Plano: docs/superpowers/plans/2026-09-23-fluxos-de-atendimento.md. |
| 20260923230000 | 0397_roteiro_encerra_com_humano_e_prazo | O roteiro de atendimento encerra quando um humano assume, no opt-out e no prazo (PR 2 do port do #1130, de @vgamkt; achados 9 e 5 da prova prática). Gatilho trg_contato_encerra_roteiro_com_humano_ou_opt_out na virada false→true de contacts.force_human ou contacts.is_blocked: o coletando do contato vira cancelled com evento roteiro_cancelado (humano_assumiu/opt_out). Um lugar para todos os escritores dessas colunas (passagem do motor, ferramenta de handoff, orquestrador, teto de orçamento, ingestão do canal) — remendar cada um era a classe que o autor pagou cinco vezes. Encerrar, não pausar: retomar as perguntas depois de uma pessoa atender fala do que ela já tratou. A pausa curta por resposta manual pelo celular (bot_silenced_until com prazo) NÃO encerra: o roteiro só não roda com a IA calada. fn_encerrar_roteiros_vencidos(p_limite) (service_role) encerra o coletando sem mensagem lida há mais de settings.expira_em_horas do grafo (padrão 72 h), em lotes com skip locked, com evento roteiro_expirado; chamada pelo relógio do follow-up (lib/relogio/executar.ts e o cron followup-flow-worker). Funções novas com as origens de EXECUTE revogadas. Sem dado reescrito. Número 0397 e carimbo conferidos livres na main e nos PRs abertos (0393 #1527, 0396 #1561). |
| 20260924053717 | 0401_conversoes_processamento_e_reenvio | API Google por conexão, protocolo de processamento, proteção de envio concluído e reprocessamento exclusivo de conversão. |
| 20260924053718 | 0402_google_captura_e_qualificacao | Captura gbraid/wbraid, etapa de qualificação opcional com FK da mesma organização, snapshot da ação/data e reprocessamento por evento. |
| 20260923140000 | 0390_fotos_no_catalogo | O produto ganha foto, e o agente de IA manda a foto junto (ideia de @vgamkt, a partir do #1130; doc 62, opção c). catalog_products.fotos text[] not null default '{}' com CHECK cardinality(fotos) <= 5: guarda CAMINHOS de Storage (<org>/<produto>/<uuid>.<jpg\|png>), na ordem da tela, e a primeira é a capa. Coluna e não tabela: pôr, tirar e reordenar é UM update da linha, e RLS (leitura da org, escrita manager+ da 0204) e cascata vêm da linha do produto. Bucket NOVO catalog-photos, privado, 5 MB, só image/jpeg/image/png (teto e formatos de imagem do WhatsApp oficial), sem policy em storage.objects — só o service_role escreve e lê, depois de a rota conferir o papel. Separado de whatsapp-media porque a limpeza de LGPD apaga a mídia das conversas: quando o agente manda a foto, o app a COPIA para a pasta da conversa, e é a cópia que a conversa possui. Quem LÊ um caminho da coluna (tela que assina, agente que copia, rota que apaga) confere o prefixo <org>/<produto>/ — a coluna é gravável pelo PostgREST e a leitura é por service role. Aditiva e idempotente (add column if not exists, constraint recriada, bucket com on conflict do update); imagem_url (0204) fica intocada. |
| 20260924200200 | 0408_prospeccao_vence_no_cron | Os candidatos da prospecção nativa ganham prazo, e o cron data-retention é quem apaga (issue #1313, fechamento de verdade). A declaração sozinha não expunha nada: nenhum código apagava prospecting_candidates por idade, e a razão escrita da isenção no teste de guarda apontava um poda de admin client que não existe. Função nova fn_expurgar_prospeccao_vencida(p_retencao_dias, p_limite) (security definer, as DUAS origens de EXECUTE revogadas, grant só a service_role), padrão 365 / piso 90 dentro do corpo (greatest(coalesce(...))), decisão do dono em 24/09 no PR #1577 alinhada ao horizonte da conversa do caso (0281) e da captação. Relógio coalesce(attempted_at, created_at) — nunca contatado conta da criação (o caso da issue: campanha montada e abandonada), contatado conta da última tentativa (lib/prospecting/worker.ts grava antes do envio), para linha antiga com contato recente não cair no meio do funil. Duas guardas que não são idade: status not in ('queued','sending') (trabalho vivo nunca entra, em nenhuma idade) e suppression_salt is null (o tombstone de LGPD da 0370 sobrevive para sempre — expurgá-lo reabriria a porta que a anonimização fechou e o trigger prospecting_refuse_erased é quem barra a reimportação). Índice parcial em EXPRESSÃO prospecting_candidates_expira_idx no predicado do expurgo (desenho da 0174): sem ele a poda diária varre a tabela inteira desde a primeira instalação que cria campanha. O cron ganha a oitava drenar() com os campos de relatório e o predicado em houveEfeito NO MESMO commit (a lição da quarta e da quinta poda: esquecer o predicado é mudo — a rodada apaga e não deixa registro); PROSPECCAO_RETENTION_DAYS em lib/env.ts no mesmo formato z.string() das cinco anteriores. PROSPECCAO sai de SEM_FUNCAO_NO_SQL e entra em DONO_NO_SQL no teste de guarda. Gate tests/invariants/retencao-prospeccao-expurgo.test.ts com a matriz de 7 casos da issue (o mais importante: o tombstone com suppression_* permanece mesmo muito antigo e continua barrando a reimportação). Fragmento de release atualizado (publicado na seção 1.48.0 do CHANGELOG.md). Número 0408 e carimbo 20260924200200 alocados pela triagem (0401–0407 com outros PRs em voo). |
| 20260923220000 | 0396_conversa_fica_com_quem_atendeu | "A conversa fica com quem atendeu" vira ajuste por empresa, DESLIGADO por padrão (ideia de @gustavorodcruz96, #1527; recorte da 0393 daquele PR, que não se reaproveita). fn_service_inbound só reabre a conversa encerrada com o último atendente (claimed, IA calada, demanda nova com dono humano, assigned_at no instante da reabertura para o prazo de devolução automática contar do episódio novo) quando organizations.settings.routing.conversation_stays_with_attendant é o booleano true e o responsável ainda é membro agent+ da organização. Desligado ou ausente — o estado de toda empresa que já existe —, a função faz o mesmo que antes: reabre como open, sem dono, e o roteamento segue. A revisão do atendimento continua nova. Sem mudança de tabela ou backfill; função idempotente no baseline e nesta migration. |
| 20260924070000 | 0403_lead_so_liga_a_propria_empresa | O lead só se liga a contato e responsável da própria empresa, no banco (revisão adversarial do #1586). As FKs de crm_leads.contact_id e owner_user_id não perguntam de qual organização; os handlers já conferiam, mas a REST do banco (/rest/v1/crm_leads, GRANT para authenticated, políticas que não olham contact_id) e a RPC fn_nascer_lead_da_conversa (security invoker) gravavam o vínculo cruzado. Cura antes do gatilho, genérica e idempotente: lead com contato de outra organização perde o contato; lead com responsável que NUNCA foi membro da organização perde o responsável (e o owner_kind); cada um ganha uma atividade lead_edited de sistema com o porquê, sem o id alheio. Responsável desligado ou viewer não é curado — o lead era dele. Gatilho trg_lead_so_liga_a_propria_empresa (BEFORE INSERT OR UPDATE OF contact_id, owner_user_id, organization_id; função security definer, search_path fixo, EXECUTE revogado de public/anon/authenticated): no INSERT confere o que vier; no UPDATE só o campo que mudou. Responsável = vínculo não revogado e papel acima de viewer (G3-04). Erro genérico PT404/PT422, que o PostgREST devolve como 404/422 sem dizer se o id existe noutra organização. Sem índice novo: as duas consultas usam a PK de contacts e o unique (user_id, organization_id). |
| 20260924060000 | 0400_moeda_do_negocio_da_conversa | O negócio que nasce de uma mensagem nasce na moeda da organização. fn_nascer_lead_da_conversa não passava currency e o insert pegava o default 'BRL' em toda organização (medido: 229 de 229 negócios em BRL numa organização em guarani), e o valor do pedido sairia para a plataforma de anúncio lido em real. Alinha à moeda da organização os negócios SEM valor que nasceram em BRL numa organização de outra moeda; os que têm valor ficam como estão. |
| 20260924180000 | 0404_comando_da_conversa_sem_reavaliar_rls | As abas da Inbox param de reavaliar a RLS de contacts duas vezes por conversa — contagem de 1,8 a 2,2 s cada numa instalação com 528 conversas (issue #1571). comando_da_conversa(conversations) nasceu INVOKER na 0203 de propósito ("respeita RLS de quem pergunta" — decisão certa e mantida no desenho); o custo só apareceu em instalação grande: as duas subconsultas em contacts rodam com o papel de quem chamam e reavaliavam tenant_isolation_contacts_all (fn_user_org_ids()/fn_is_platform_admin(), SECURITY DEFINER não-inlináveis) DUAS vezes por linha, além da policy da própria linha. Medido na issue (PostgreSQL 17.6): authenticated 823–846 ms → postgres 25 ms → authenticated com a função DEFINER 75–94 ms. Por que não enfraquece a RLS: a função recebe a LINHA da conversa, que só chega ao chamador depois da RLS de conversations; lê só force_human/is_blocked do contato DESSA linha, com ct.organization_id = $1.organization_id nas duas subconsultas (a policy de UPDATE de conversations não confere contact_id e a FK não passa pela RLS, então sem o predicado uma conversa apontada para contato de outra empresa leria os dois bits dele), e devolve apenas o texto do comando (abas conferidas antes e depois no papel authenticated: idênticas). O parâmetro virou SEM NOME — a PostgREST expõe coluna calculada namedada em /rpc (docs v10/v12: "unnamed parameter … prevent it from being exposed as an RPC"), e sob definer aquele endpoint aceitaria uma linha fabricada e devolveria force_human/is_blocked de qualquer contact_id cross-tenant; sem nome, ?select=/?filtro= seguem iguais e o /rpc some (por isso o notify pgrst: forma de schema mudou). DROP + CREATE (o Postgres recusa create or replace trocando nome de parâmetro) sem cascade — medido que nada em supabase/ depende da função; grants das DUAS origens refeitos no mesmo arquivo. Idempotente no baseline.sql pelo APÊNDICE, antes da varredura anon (a definição do meio segue como a 0203 a escreveu; a anotação de doutrina ao lado dela aponta para cá), e a varredura anon do baseline passou a alcançá-la preservando os grants. Número 0404 e carimbo 20260924180000 conferidos livres (main até 0400; PRs abertos 0401×3, 0402×2, 0403). |
| 20260924190000 | 0405_revogacao_de_membro_desatribui_conversas | Ao revogar um membro, as conversas abertas dele voltam para a fila (#1562). fn_routing_member_revoked não desatribuía as conversas abertas de quem era revogado da organização: uma conversa assumida ficava com assigned_to_user_id apontando para o usuário revogado e bot_silenced_until = 'infinity', deixando a conversa muda sem que nenhum atendente a visse como sua nem a IA respondesse. Agora, a função desatribui todas as conversas abertas (status in ('open','pending','claimed','ai_handling')), limpando assigned_to_user_id, assigned_to_user_name, assignee_kind, resetando status para open, soltando o silêncio do bot com a regra do release de fn_conversation_assign (bot_silenced_until = null só quando não há last_handoff_at; conversa que a IA passou a um humano continua na fila humana), registrando evento em conversation_assignment_events (reason = 'member_revoked') e acordando o roteamento por canal (fn_wake_channel_routing). Amplia o CHECK inline conversation_assignment_events_reason_check com member_revoked (drop if exists + add, molde da 0385; as linhas existentes cabem no conjunto novo): sem isso, toda revogação de membro com conversa aberta falhava com 23514. |
| 20260924200000 | 0406_logo_por_tema | Logo escuro opcional por instalação/organização; preserva o padrão e o isolamento dos arquivos. Numerada 0401 na branch de origem (PR #1588, de @vitorlacerdadigital); renumerada na triagem porque a 0401 ficou com o #1566. |
| 20260924200100 | 0407_social_identity_na_fusao | A junção de fichas leva o social_identity do contato que sai (issue #1455). fn_mesclar_contatos já herdava name, display_name, birthdate, email, phone_number e a origem do waha_lid da ficha que sai — a identidade social ficou de fora. Com o #1444 o upsertSocialContact (lib/channels/zernio/ingest.ts) filtra is_merged_into is null, do mesmo jeito que fn_upsert_wa_contact faz no WhatsApp: sem herdar, a próxima DM de quem veio por Instagram não acha ficha viva com aquela identidade e cria uma nova, refazendo a duplicata que a fusão acabou de desfazer. Mesmo desenho do waha_lid: o perdedor entrega o social_identity quando o vencedor não tem um, com guarda de unicidade contra um TERCEIRO contato vivo (índice parcial contacts_org_social_identity_unique — a lápide já tirou os perdedores da disputa), e o update usa coalesce para não sobrescrever a identidade que o vencedor já tem. Redefine a função inteira a partir da definição vigente (0262_cliente_pela_agenda.sql), repetindo o par revoke/grant; sem coluna nova, sem dado reescrito, sem policy. Apêndice idempotente no supabase/baseline.sql, antes da varredura de anon (a cerca tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts reprova função criada depois dela). Número e carimbo: 0407/20260924200100, alocados pelo coordenador da fila de PRs de 24/09. O PR chegou com 0401/20260924120000, o próximo livre na main quando foi aberto, mas 0401 também era pedido por PRs abertos (#1566, que fica com ele, e #1588); a renumeração foi feita pelo mantenedor, como o template do PR combina. Gate: tests/unit/fusao-de-contato-herda-a-identidade-social.test.ts. |
| 20260925000000 | 0409_knowledge_content_hash | A base de conhecimento só reindexa o que mudou (de @vgamkt, #1130; numerada 0249 na branch de origem). ai_knowledge_sources.content_hash guarda o hash do conteúdo efetivamente indexado; o rag-indexer pula a fonte cujo hash é igual, que está success e cuja versão ativa foi calculada com o mesmo modelo de embedding. É o que torna barato o "Preparar tudo de novo" (POST /api/v1/ai/knowledge/reindex-all). Coluna nullable, sem backfill: a primeira indexação depois do update grava o hash. Sem função nova. |
| 20260925120000 | 0410_catalogo_requesty | Catálogo da Requesty. Roteador OpenAI-compatível como a OpenRouter, entra pelo vocabulário aberto da 0127 e pela mesma fábrica @ai-sdk/openai com .chat(). Cinco modelos com ids e preços verificados em GET https://router.requesty.ai/v1/models (convertidos para centavos por milhão), com supports_vision porque num roteador é o catálogo que decide visão. Não insere em ai_pricing, mas a linha aparece: o backfill 0113 do baseline.sql a deriva de ai_models no próximo update.sh. ai_pricing e precoDoCatalogo resolvem só por model_id, sem provider, então o mesmo id (openai/gpt-4o-mini etc.) na OpenRouter e na Requesty divide o preço com ou sem essa linha. Sem is_default_for_provider. Idempotente (on conflict do update), sem coluna nem função nova. |
| 20260925150000 | 0412_birthdate_na_proposta_de_dado | O agente passa a poder propor a DATA DE NASCIMENTO do contato, pelo mesmo fluxo de proposta com aprovação humana que já existe para nome, e-mail e telefone (issue #1546 — clínica de nutrição que quer mandar parabéns e disparar por aniversário). A coluna contacts.birthdate existe desde cedo e a rota de PATCH já a aceita; o que faltava era a fila. O vocabulário fechado de contact_field_proposals.campo (0123, três vocábulos de propósito: o que entra aqui vira escrita em contacts) ganha birthdate — ALARGAMENTO do conjunto, não abertura de porta, e a outra metade da fronteira é CAMPOS_PROPONIVEIS em lib/contacts/proposta-de-dado.ts, que nasce igual. Sem o vocábulo, a proposta passava pela validação do código e morria em 23514 na confirmação: modo de falha silencioso, com a IA ouvindo "não consegui registrar" para um dado que o cliente repete com naturalidade. valorAceitavel valida a MESMA forma que contactPatchSchema exige (AAAA-MM-DD) mais o calendário de verdade (1990-02-30 tem a forma e não existe) e a data não futura — o cron contact-birthdays só aciona o que é data de nascimento. Dado tocado: nenhum; as linhas existentes da fila cabem no conjunto novo. Idempotente (drop if exists + add — o nome existe desde a 0123 e um add solto reprovaria em 42710 no clone que segue a cadeia). No baseline.sql, o vocábulo entrou por EDIÇÃO NO LUGAR do bloco ÚNICO da constraint, e não por um bloco novo no apêndice: baseline-constraint-reconstruida reprova add constraint repetido, porque o bloco antigo falharia no update.sh de um clone cuja fila já tenha uma proposta de nascimento e a tabela ficaria sem constraint entre o drop e o add que funciona. Nenhuma função nova em public ⇒ o item 9 da doutrina não é acionado. |
| 20260925170000 | 0414_redact_unificado_chama_a_cascata | Os DOIS caminhos de anonimizar passam a redigir na MESMA função (issue #1504, acompanhamento do #1501). O #1501 consertou o sintoma pelo gatilho, mas a causa ficou: fn_lgpd_cascade_redact_contact (pedido formal, lib/lgpd/redact-cascade.ts) e fn_lgpd_anonymize_contact (botão da ficha) eram duas redações com conjuntos diferentes, e o botão reescrevia o CONTATO e mais nada — as 11 tabelas da issue (orders, sales, voice_calls, prospecting_candidates, agent_cases, agent_case_events, demandas, agent_inbox_items, agent_case_chat_messages, passagens_de_atendimento, entregas_de_aviso_de_caso) e contacts.consent/source_metadata/tags só eram alcançadas por quem abria pedido formal. O que muda: o portão do botão CHAMA a cascata canônica (perform public.fn_lgpd_cascade_redact_contact(org, contato, null)) e para de escrever por conta própria — o update contacts sai do corpo dele. O portão continua portão: autoridade (suporte, papel admin, platform_admin e MFA comprovado, tudo antes da escrita), mutex (fn_service_lock antes do for update, ordem da 0229) e o contrato {already_anonymized, anonymized_at} com a data ORIGINAL na retomada (a igualdade exata que lgpd-agenda-lock-order cobra na segunda chamada). p_request_id nulo no caminho do botão: não há pedido de titular, e fila (idempotente por (bucket, object_path)) e auditoria já aceitam nulo — mesmo formato da 0391. Rótulo único: o contato passa a ficar com Cliente Anonimizado #<8>, o que o pedido formal já gravava; a cópia do diálogo (AnonymizeDialog.tsx + dicionário + zh-CN) acompanha. Dado tocado: nenhum reescrito por esta migration — o efeito novo é nos contatos anonimizados A PARTIR dela. Sem coluna, sem constraint, sem tabela nova; a função fn_lgpd_anonymize_contact é redefinida (CREATE OR REPLACE com assinatura igual). No baseline.sql, o mesmo corpo vai no APÊNDICE, antes da VARREDURA anon, espelho da cadeia (apendice-do-baseline-nao-diverge-da-cadeia). Gates: tests/invariants/lgpd-redact-unificado-alcanca-pelo-catalogo.test.ts (catálogo: TODA tabela com FK para contacts tem decisão escrita de redigir/manter, as 11 da issue na cascata, os dois campos zerados e a chamada única) e tests/unit/redact-unificado-os-dois-caminhos-chamam-a-mesma-funcao.test.ts. |
| 20260925163550 | 0413_provedor_personalizado | Provedor personalizado compatível com OpenAI (#1642). A tela de Credenciais oferece um endpoint do próprio operador (OmniRouter, 9Router, LiteLLM hospedado, proxy corporativo), num endereço público: a base URL passa pela régua de destino de organização (decisão 22-d) no teste, na validação e em cada chamada do agente. ai_provider_credentials.base_url text nullable com CHECK ^https?:// — o ENDEREÇO mora na LINHA da credencial, e não em .env nem no binding do ponto, porque cadastro, validação e turno do agente têm de ler a mesma escolha. A chave continua cifrada pelo caminho da 0023 (nenhuma coluna de segredo nova). View ai_provider_credentials_safe recriada com a coluna no FIM (create or replace não reordena) e o grant POR COLUNA da 0150 reemitido com base_url — sem ele a view nova responderia permission denied para todo manager e a tela viraria []. Nenhum provedor nativo muda: a coluna nasce null, o resolvedor só a lê quando o provider é custom (query separada, para um clone sem a migration não derrubar os quatro) e o registry recusa a chamada sem endereço em vez de cair no endpoint da OpenAI. Teste de conectividade ANTES de salvar (POST /api/v1/ai/credentials/test, GET {base}/models, timeout 10s) com o mesmo validador que roda em segundo plano sobre a linha gravada. Aditiva e idempotente; sem função nova. Apêndice no baseline.sql antes da varredura de anon. |
| 20260926030000 | 0421_jev_observacoes | As observações do Jev, tarefa a tarefa (onda 2, bloco 2.1). Toda tarefa nova do Jev nasce observando: ele opina, o mecanismo de hoje decide, e o cartão mostra a concordância. public.jev_observacoes guarda, por resposta do Jev, a tarefa, o estado dela (observando/decidindo, CHECK), o rótulo, a probabilidade e a confiança do Jev, o rótulo do mecanismo de hoje (NULL = ele não decidiu) e concordou GERADA (NULL sem par), mais conversation_id/message_id/job_id SEM FK (a linha não guarda nada da pessoa e não pode travar a anonimização nem cair com o histórico). Tabela e não llm_calls: lá não há coluna para rótulo nem probabilidade, e uma chamada com várias perguntas é uma linha para N tarefas. RLS: só SELECT por membro (tenant_isolation_jev_observacoes_select); a escrita é do servidor. Índices (org, tarefa, created_at desc) para o cartão, (created_at) para a poda e ÚNICO parcial (org, tarefa, message_id) where message_id is not null — o retry do job pergunta de novo sobre a mesma mensagem, e a segunda resposta contaria em dobro (o gravador faz on conflict do nothing). fn_expurgar_observacoes_do_jev(p_retencao_dias, p_limite) — security definer, padrão 90 / piso 30 no CORPO (a janela da concordância), revogada de public, anon e authenticated, só service_role — chamada em lotes pelo cron data-retention (JEV_OBSERVACOES_RETENTION_DAYS). Aditiva e idempotente. Apêndice no baseline.sql antes da varredura de anon (a função nova nasce antes dela) e, com isso, antes das proteções e travas do fim. |
| 20260925200000 | 0416_autoria_em_nome_de_na_mensagem | Quem enviou por token passa a ser conhecido (issue #1613, item C — "em nome de"). A única porta por token que envia mensagem era também a única que não registrava quem decidiu o envio: o token é da organização, a linha nascia sent_by_user_id nulo e o balão lia "Sistema" — a conversa perdia que foi uma pessoa no ERP/cobrança/agenda que mandou. messages.sent_on_behalf_of_user_id (uuid nullable, SEM backfill e sem FK, como sent_by_user_id) guarda a pessoa; os dois nomes vão em metadata.sent_on_behalf porque o balão não faz join. O gate é duplo e vem do FORA do corpo: o escopo novo messages:on_behalf na LINHA DO TOKEN (resolveAuthDual passa a devolver scopes) e o membership — user_organizations com revoked_at is null e papel agent+ pela régua roleAtLeast —, com a organização vinda da linha do token; sem escopo, com sessão de navegador, usuário de outra org, revogado ou viewer, 403 antes de qualquer escrita. O handler recusa alto se on_behalf_of_user_id chegar pelo input sem o ctx que só a rota preenche (a mesma entrada atravessa o MCP — falha fechada). Auditoria com OS DOIS atores: actor_api_token_id = o token que apertou, e a pessoa em metadata.on_behalf_of_user_id (actor_user_id segue nulo, como em todo envio por token: a coluna registra quem autenticou). Dado tocado: nenhum reescrito; a coluna nasce null em toda linha existente. Sem função, sem constraint, sem policy nova ⇒ o item 9 da doutrina não é acionado; apêndice idempotente no baseline.sql, logo depois do bloco da 0415 e antes da varredura anon. |
| 20260925180000 | 0415_teto_de_tokens_ativos_por_organizacao | O teto de tokens ATIVOS por organização passa a ser do BANCO (issue #1448 — a issue mesma diz: "mitigar não é fechar"). api_tokens não tinha CHECK, trigger nem índice que limitasse QUANTOS tokens vivos uma organização mantém, então o teto por token da Spec 11 §7 (60/min) era multiplicável por quem já estava dentro — e o teto de ESCRITA (30/min, o que protege o número de WhatsApp) não tem agregado nenhum, enquanto o 600/min por organização do PR #1446 segura só a leitura. Gatilho trg_teto_de_tokens_ativos (BEFORE INSERT, função fn_teto_de_tokens_ativos security definer com search_path fixo e revoke execute das duas origens, molde da 0403): conta os tokens da organização que estão VIVOS — revoked_at is null e (expires_at is null ou ainda não vencido) — e recusa quando >= 50. Contar só o vivo é o que preserva a rotação legítima: revogar o antigo e emitir o novo passa sempre, e revogado/expirado libera espaço na hora. 50 é o número escolhido e está em UM lugar só (o corpo da função, na migration e no apêndice do baseline): ninguém mantém cinquenta integrações, e mesmo assim a mudança de produto futura é mexer uma constante — o limite chega ao usuário pela mensagem do próprio erro, que é quem o sabe. Quem já está acima não é tocado: o gatilho recusa só inserção, então instalação com mais de 50 tokens hoje segue funcionando e não emite mais nenhum (pergunta 3 da issue). Os tokens efêmeros do runtime contam de propósito — há marcador forjável por prefix/name (a policy de api_tokens é for all para admin, então PostgREST direto escreve qualquer coluna) e um teto com exceção seria teto com porta; com TTL de 300 s e revogação no fim do run, eles mal aparecem na contagem. Erro próprio: raise exception com SQLSTATE PT409 (desenho do PT404/PT422 da 0403) e mensagem PT-BR dizendo o limite e mandando revogar um token não utilizado para liberar espaço; a rota POST /api/v1/settings/api-tokens reconhece o código e devolve 409 api_token_teto_atingido com a mensagem do banco — em vez do 500 genérico que devolveria —, e o onError: showApiError do useCreateApiToken já faz o toast chegar à tela. Código declarado em lib/api/errors.ts. Sem coluna, sem backfill, sem policy nova; apêndice idempotente no baseline.sql ANTES da varredura anon. Gates: tests/unit/teto-de-tokens-ativos-da-organizacao.test.ts (a tripla migration × apêndice × MANIFEST, o predicado que ignora revogados e expirados, o >= que recusa no teto+1, os revokes, o mapeamento PT409 → 409 do emissor e o código declarado) e tests/invariants/teto-de-tokens-ativos-da-organizacao.test.ts (Postgres real: dentro do teto passa, no teto+1 recusa com a mensagem, revogado libera espaço, e a instalação acima do teto não é derrubada). |
| 20260925235500 | 0418_publicar_com_provedor_personalizado | Agente com o provedor personalizado passa a PUBLICAR (acompanhamento da 0413, #1642). A fn_publish_ai_agent_version conferia o modelo só em ai_models, e nada escreve linha custom ali — o catálogo é GLOBAL e o endpoint é de cada empresa. Medido numa VPS real: credencial custom validada, rascunho com um modelo que o endpoint devolveu em /models, e todo "Publicar" respondendo model_not_found. O que muda: para provider = 'custom', "o modelo existe" passa a ser conferido em ai_provider_credentials.models_available da credencial da versão (a lista que o próprio endpoint devolveu, na linha já conferida quanto a organização, ativa e validada); custom sem credencial própria segue model_not_found. Os provedores nativos seguem pela consulta ao catálogo, sem mudança. Escrever os modelos em ai_models foi recusado: o id servido pelo endpoint de uma empresa apareceria no catálogo da instalação inteira. create or replace da versão de 5 argumentos com a mesma assinatura e os mesmos grants (as de 3 e 4 só delegam). Sem coluna, sem dado tocado. Apêndice no baseline.sql antes da varredura anon. Gate: tests/invariants/publicar-com-provedor-personalizado.test.ts. |
| 20260925223000 | 0417_message_failed_vira_gatilho | message.failed passa a ser gatilho de verdade (issue #1614). A 0239 colocou o tipo na lista de fn_event_log_e_registro — na época ele era registro, sem consumidor, e a linha nascia done para não parecer fila entupida (#753). Com a issue o tipo ganhou consumidor (automationRulesHandler, via ENTIDADE_ESPERADA_POR_GATILHO) e a lista virou armadilha: fn_event_log_marca_registro (BEFORE INSERT) trocaria pending por done antes de o drain selecionar, e o handler registrado nunca rodaria — sem erro e sem log, o mesmo modo de falha mudo que a 0239 combate. A função é redefinida sem o tipo (create or replace, mesma assinatura e mesmos grants; revoke/grant repetidos para o arquivo ficar autocontido). Sem backfill de propósito: as linhas antigas nasceram done como registro, e reprocessá-las faria a regra rodar hoje por uma falha de semanas atrás, com o estado do contato de agora. No baseline.sql o corpo entra por EDIÇÃO NO LUGAR do bloco ÚNICO (mesmo desenho da 0412), porque função criada depois da varredura de anon é reprovada pela cerca varredura-anon-e-o-ultimo-bloco. Gate: tests/unit/evento-de-fato-nao-fica-pendente.test.ts — a cobrança "tipo com consumidor na lista de registro" passa a ler a ÚLTIMA definição (a que o banco usa), já que a 0239 já rodou em toda instalação e não pode ser apagada. |
| 20260926035000 | 0420_motivo_de_ganho | Motivo de ganho nativo (issue #1536). crm_leads.won_reason text, nullable, sem backfill e — deliberadamente — sem CHECK: a obrigatoriedade é opt-in por funil (settings.won_reason_required) e a lista é settings.won_reasons, as duas decididas no servidor em lib/leads/campos-exigidos.ts; uma CHECK tornaria o motivo exigido para todo install e quebraria a escrita de ganho existente num upgrade, enquanto a CHECK da perda (#917) fica intocada. Gravação na MESMA escrita que muda o estado (mesmo desenho do lost_reason), validação de vocabulário em aplicação porque não há trigger de ganho. Aparece em crm_get_lead e no envelope do webhook (LEAD_PUBLIC_FIELDS). Apêndice idempotente no baseline.sql antes da varredura anon. |
| 20260926022806 | 0419_rascunho_sugerido_por_integracao | Rascunho sugerido por integração, aberto por link e enviado só com clique (issue #1611). Quem integra tinha duas saídas ruins para a mensagem que precisa sair de uma pessoa: enviar por token (a bolha diz Sistema, sent_by_user_id nulo e a IA não é silenciada como no envio humano) ou copiar-e-colar. A tabela guarda o TEXTO no servidor — organization_id, conversation_id, body (CHECK de 4096 caracteres, o mesmo teto do envio), source, created_by_api_token_id, expires_at (janela de 24h por padrão), consumed_at e consumed_by_user_id — e a caixa de entrada abre por ?rascunho=: o Composer recebe o texto via initialDraft e mostra a origem, e o envio segue sendo clique humano (sent_via='user', agente silenciado como sempre). RLS tenant_isolation_conversation_drafts_all (for all, organização nos dois sentidos) porque a leitura é da sessão do atendente e o consumo é um UPDATE da mesma sessão; a escrita da integração usa service role com organization_id programático na rota. LGPD: trg_apagar_rascunhos_ao_anonimizar (em contacts, na transição is_anonymized false → true, molde de trg_redigir_tarefas_ao_anonimizar) apaga os rascunhos das conversas do contato anonimizado — o body é texto escrito para a pessoa, e sem FK para contacts nem a cascata nem o invariante de cascata a enxergavam. fn_apagar_rascunhos_do_contato_anonimizado revogada de public/anon/authenticated. Apêndice no baseline.sql antes da varredura anon (é o que faz as travas do suporte cobrirem a tabela já na instalação). Guardado por tests/unit/rascunho-sugerido.test.ts, pela linha nova em tests/invariants/rls-isolation.test.ts e por tests/invariants/lgpd-rascunho-do-contato-anonimizado.test.ts. |
| 20260926040000 | 0422_skill_pointers_legados | A tela de Skills não cai quando a instalação veio do formato legado. Instalações antigas guardavam o nome e a versão ativa em slug/active_version_id; o contrato atual lê name/version_id, e um ponteiro nulo fazia o PostgREST recusar a consulta inteira. A migration adiciona as colunas canônicas quando faltam, copia os valores legados sem apagar as colunas antigas, instala a FK para novas escritas e recompõe os índices únicos por organização/plataforma. Registros que não têm versão recuperável ficam fora da resposta da rota em vez de derrubar a tela. Aditiva, idempotente e sem remoção de dados. Gate de rota em app/api/v1/ai/skills/route.test.ts e prova de banco em tests/invariants/skill-pointers-legados.test.ts. |
| 20260926040100 | 0423_hardening_fn_espelha_nome | Hardening de função legada. Algumas instalações trazem fn_espelha_nome_e_name() do CRM anterior, sem search_path fixo. A migration só quando a função existir fixa o caminho em public, pg_temp, eliminando a resolução de objetos influenciável pela sessão. Não altera dados nem cria a função em instalações novas. |
| 20260926040200 | 0424_skill_versions_legadas_preservadas | Desinstalar uma skill legada não apaga seu histórico. Instalações anteriores guardavam skill_versions.pointer_id com ON DELETE CASCADE; apagar o ponteiro removia as versões imutáveis, contrariando o contrato de auditoria e rollback. Quando essa coluna existir, a migration reconstrói a FK com ON DELETE SET NULL, preservando a versão e soltando apenas o vínculo obsoleto. |
| 20260926120000 | 0425_retomada_como_novo_negocio | Retomada de negócio perdido guarda a cadeia de tentativas (issue #1538). Num funil com settings.reabertura = "novo_negocio", mover um lead encerrado para uma etapa aberta não o reabre: a rota devolve 409 reabertura_cria_novo e a saída é POST /api/v1/leads/{id}/retomar, que cria um lead novo (source = 'retomada') apontando para o encerrado pela coluna nova crm_leads.retomado_de_lead_id uuid — daí "quantas tentativas até fechar" é derivado por SQL, sem ler source_metadata. FK para a própria tabela com ON DELETE SET NULL (apagar um negócio solta o ponteiro da tentativa nova, em vez de recusar a exclusão LGPD), constraint guardada por pg_constraint e índice parcial where retomado_de_lead_id is not null. Aditiva e idempotente; coluna nasce null em toda linha existente (nenhuma retomada existia antes disto). Sem função nova, sem policy nova: o escopo de leitura/escrita é o de crm_leads de sempre. |
| 20260926150000 | 0426_motivo_de_perda_com_categoria | Motivo de perda com categoria e a etapa de onde o negócio saiu (issue #1537). Três mudanças aditivas: (1) crm_leads.lost_from_stage_id uuid com FK on delete set null e constraint guardada por pg_constraint — em que etapa ABERTA o negócio morreu, sem a qual o relatório não diz em que momento o funil vazou; nasce null em toda linha existente, sem backfill (derivar retroativamente seria inventar dado). (2) fn_crm_lead_close_on_stage grava a origem SÓ na transição para lost (um card já perdido mantém a origem verdadeira; reabrir zera) — o mesmo gatilho que já deriva status/closed_at, sem segundo gatilho na mesma transição; todo caminho de perda move a etapa e ele é o único escritor de status (P-02). (3) fn_validate_lost_reason_required compara o RÓTULO de settings.lost_reasons quando o item é { label, categoria } (jsonb_typeof(e) = 'object' → e->>'label', string → #>> '{}'): sem isso o trigger recusaria com 22023 um motivo que a própria tela ofereceu. Texto puro segue valendo igual — nenhum funil migra dado. Funções redefinidas com create or replace (assinatura e grants inalterados, revoke/grant repetidos para o arquivo ficar autocontido) e corpos também EDITADOS NO LUGAR no baseline.sql (bloco único do close, ÚLTIMA definição do validate) mais apêndice idempotente da coluna. Gate: tests/unit/lost-from-stage-id-no-gatilho.test.ts. |
| 20260926160000 | 0427_probabilidade_de_ganho_por_etapa | Probabilidade de ganho por etapa, base da previsão ponderada do funil (issue #1535, de @webtecnica). Aditiva: crm_stages.win_probability smallint com CHECK null or 0..100 (crm_stages_win_probability_range, recriada com drop ... if exists para o arquivo ser reaplicável). Nasce null em toda linha existente e null quer dizer "etapa sem probabilidade calibrada" — a regra de previsão (lib/leads/previsao.ts) reporta esses negócios num balde À PARTE, nunca como zero em silêncio. Etapas is_won/is_lost valem 100 e 0 NA REGRA, não gravado: gravar seria um segundo lugar para a mesma verdade divergir. Nenhum backfill: nada muda até o gestor calibrar. Apêndice idempotente no baseline.sql, depois do da 0426. |
| 20260926170000 | 0428_candidatos_ao_golden_na_tabela | O candidato ao golden set sai do disco e vira linha de rótulo (issue #1695). O matcher de skills (F3-09) e o classificador de etapa (F3-11) gravavam JSON em GOLDEN_CANDIDATES_DIR: dentro do repositório em desenvolvimento (git add -A publicava conversa de cliente — medida na issue) e no disco do contêiner em produção, onde nenhuma tela lê, some na atualização da imagem e fica FORA da cascata de anonimização. O #1708 redigiu o texto pelo scrubMessage e gitignorou os dois prefixos; esta migration tira o candidato do disco. public.golden_candidates guarda SÓ rótulo — fonte (skill_match_miss/stage_classifier_divergence) + skill/motivo ou estagio_sugerido/estagio_confirmado, com CHECK de formas disjuntas — e ponteiros lead_id/job_id SEM FK (mesmo motivo da 0421: não travar a anonimização nem cair com o histórico); a conversa que a curadoria lê fica atrás do ponteiro, não numa cópia fora da cascata. Idempotência do retry por dois índices ÚNICOS parciais (um candidato por skill+job, uma divergência por job) + on conflict do nothing do gravador — o "um arquivo por candidato" do tempo em disco. RLS: só SELECT por membro (tenant_isolation_golden_candidates_select); escrita só do servidor. fn_expurgar_candidatos_do_golden(p_retencao_dias, p_limite) — security definer, padrão 90 / piso 30 no CORPO, revogada de public, anon e authenticated, só service_role — drenada em lotes pelo cron data-retention (GOLDEN_CANDIDATES_RETENTION_DAYS). Aditiva e idempotente. Apêndice no baseline.sql antes da varredura de anon. |
| 20260926200200 | 0432_poda_de_midia | A retenção de mídia passa a existir. fn_enfileirar_midia_vencida(p_limite) (security definer, só service_role) enfileira em storage_redaction_queue o arquivo de mensagem mais velho que organizations.media_retention_days (piso 30; zera messages.media_storage_path, a mensagem fica) e o arquivo órfão de whatsapp-media (pastas org/<conversa>/ e org/avatars/ sem referência, com um dia de carência; org/templates/ nunca). O cron storage-redaction remove pelo Storage API. Medido em 23/09/2026: 1,30 GB órfãos levaram um Supabase gratuito à restrição 402. |
| 20260926223000 | 0434_reenfileiramento_de_midia | Reenfileirar um caminho de mídia que já saiu volta a funcionar; linha deleted antiga é expurgada (issue #1739, achado na triagem do #1731). As DUAS inserções de fn_enfileirar_midia_vencida usavam on conflict (bucket, object_path) do nothing sobre um unique (bucket, object_path), e o worker marca a linha como deleted sem nada a remover depois — dois defeitos: um arquivo NOVO gravado num caminho que já saiu era engolido em silêncio (nunca mais podado nem pela retenção nem pela LGPD) e a fila crescia sem teto. Medido antes de consertar (#1739, passo 1): o reaproveitamento de object_path EXISTE — app/api/v1/cron/contact-avatars/route.ts:204 grava ${org}/avatars/${contactId}.jpg com upsert: true (caminho estável por contato, regravado a cada refresh de 7 dias, enfileirado de novo pela cascata lib/lgpd/redact-cascade.ts e pelo próprio cron) e workers/media-persist-worker.ts:122 usa storagePathFor = {org}/{conversa}/{mensagem}.{ext} (determinístico por mensagem, regravado no retry); os outros três emissores (conversations/[id]/media, products/[id]/fotos, channels/partner/templates/media) usam randomUUID() e nunca reaproveitam. Logo o item 1 da issue não é teórico e o conserto é o on conflict ... do update, não só o expurgo. Regra: do update set status='pending', attempts=0, enqueued_at=now(), processed_at=null, error_message=null where storage_redaction_queue.status in ('deleted','skipped')— reabre SÓ o que já terminou;pendingefailedem curso não são tocados (é owhere que garante). O filtro de órfãos passa a contar só linha em curso como «já na fila» (status not in ('deleted','skipped')): sem isso o not existssegurava o caminho antes de o conflito acontecer e odo updatejamais rodaria. **Expurgo:**delete ... where status='deleted' and request_id is null and coalesce(processed_at, enqueued_at) < now() - 90 daysno CORPO da mesma função, ou seja, no cron diáriomedia-retention— sem cron novo, sem coluna, sem índice, sem FK parastorage_redaction_queue(medido no baseline).failednão é expurgada (a issue manda não mexer; é o registro de uma remoção que não passou das 3 tentativas). A linhadeleted de pedido LGPD (request_idnão nulo) também fica: é o único registro por objeto de que a mídia do titular saiu do bucket — o worker só troca ostatuse o cronstorage-redactionnão audita a remoção física (ajuste da triagem). Mesma assinatura (1 argumento) e parrevoke/grantrepetido; corpo EDITADO NO LUGAR no apêndice dobaseline.sql(mesmo desenho das 0417/0426). Gate:tests/invariants/poda-de-midia-reenfileiramento-medido.test.ts(Postgres real:deleted/skippedreabrem,pending/failedficam, expurgo dos 90 dias e a segunda rodada continua idempotente) etests/invariants/poda-de-midia-expurgo-preserva-lgpd.test.ts(a linhadeletedde pedido LGPD sobrevive ao expurgo). | |20260927015500|0435_contagem_do_expurgo_da_fila| **A contagem do expurgo da fila aparece no retorno e na trilha (issue #1765, continuação da #1739).** A 0434 passou a expurgar, no CORPO defn_enfileirar_midia_vencida, a linha deleted da RETENÇÃO com mais de 90 dias (request_id is null) — o DELETE está lá, roda todo dia, e a sua contagem não aparecia em lugar nenhum: a função devolvia só {vencidas, orfas}, e app/api/v1/cron/media-retentionauditavaretention.sweep_runsó quandovencidas + orfas > 0. As duas lacunas são a MESMA linha de código vista de dois lados: **a rodada que só expurgou (as duas contagens zeradas) não auditava**, sendo que é a rodada em que a fila perde linhas de verdade; e a linha de rastro que existia não dizia quantas saíram da fila. **O conserto:** get diagnostics v_expurgadas = row_countlogo após o DELETE do passo 0 e a chaveexpurgadasnojsonb_build_objectde retorno — contagem de linha APAGADA nesta chamada, que é o que permite ao cron decidir se houve efeito (uma contagem do que «será» apagado não permitiria, porque o DELETE já aconteceu quando a função retorna). **No cron:**expurgadas entra no acumulado e na soma da condição de auditoria (vencidas + orfas + expurgadas > 0), e sai na metadata do retention.sweep_run— que é o que o runbook de custo do Supabase lê para saber quanto a fila perdeu. O expurgo NÃO entra na condição de PARADA do laço de tandas: ele é livre dep_limite e a segunda chamada da mesma rodada devolve 0 para ele de qualquer jeito, então parar por causa dele seria parar uma tanda antes de enfileirar o que ainda cabe. **O que NÃO muda:** janela de 90 dias, filtro (status='deleted', request_id is null, coalesce(processed_at, enqueued_at)), ordem dos passos e as demais podas — failed, skippede a linha de pedido LGPD continuam de fora, cada uma pelo motivo escrito no cabeçalho da 0434. Mesma assinatura (1 argumento) e parrevoke/grantrepetido. A 0434 está aplicada (1.54.0) e não é editada: o corpo nobaseline.sqlé EDITADO NO LUGAR do bloco dela (mesmo desenho das 0417/0426/0434), derivado deste arquivo, não reescrito. Gate:tests/invariants/poda-de-midia-contagem-do-expurgo.test.ts(Postgres real: a contagem bate com as linhas que saíram, a rodada que só expurgou devolvevencidas/orfaszerados comexpurgadas > 0, a segunda rodada expurga 0, e o toEqualcongelado dopoda-de-midia.test.ts:102recebe o campo novo no objeto esperado, porque otoEqualdo Vitest é exato EM CHAVE — a chave nova é aditiva em VALOR, não em chave, e sem essa edição otest:invariantssaía != 0 exatamente nos trêstoEqual congelados que comparam o retorno inteiro (:102, e o irmão poda-de-midia-reenfileiramento-medido.test.ts:150e:176); a edição não afrouxa nada — os três continuam exatos e passam a fiscalizar a contagem do expurgo também, e a justificativa está no PR com a válvula DESKCOMM_GOV_INVARIANTS_EDIT=1exportada) etests/unit/cron-media-retention-audita-o-expurgo.test.ts(a rota soma, audita a rodada que só expurgou e publicaexpurgadasna metadata). | |20260927140100|0440_etapa_avisa_na_central| **A etapa pode avisar a equipe na Central quando um negócio entra nela.**crm_stages.avisar_na_central boolean not null default false. Numa venda com pagamento na entrega, o momento que pede ação da equipe é o cliente confirmar o pedido com os dados — uma etapa, não o is_won(o ganho só vem com a entrega). Qual etapa é essa é decisão da organização, daí a marca por etapa, desligada por padrão. Quem lê élib/leads/aviso-de-etapa.handler.ts, no evento lead.stage_changed: abre um aviso othercomref_kind = lead(o destino «Abrir negócio» que a Central já tem), sem dado pessoal no texto — só o nome da etapa — e sem empilhar enquanto o anterior do mesmo negócio e etapa estiver aberto. **Por queothere não um kind novo:** um kind novo exigiria reconstruir o CHECK deagent_inbox_itemsno bloco único do baseline e na cadeia, para um aviso que é genérico por natureza. Aditiva e idempotente. | |20260927140200|0441_sons_dos_avisos| **O bucket privadoorg-soundspara os sons dos avisos da Central** — o negócio que entrou numa etapa que avisa (0440) e o pedido de pessoa (a IA passou a conversa, ou ficou sem saldo no provedor). 1 MB, MP3/OGG/WAV,public = falsee **nenhuma** policy emstorage.objects: só o service_rolelê e grava, pela rotaapp/api/v1/settings/sons(GET qualquer membro, com URL assinada de 1 h; POST/DELETEmanager+, tipo farejado pelos bytes, caminho gerado no servidor sob <organization_id>/). O caminho fica em organizations.settings.sons_de_aviso — jsonb, sem coluna nova (C da DIRC: é configuração da organização, não entidade). Idempotente (on conflict do update). Gate: tests/invariants/sons-dos-avisos.test.ts. | | 20260927140300|0442_aviso_da_central_no_barramento| **O aviso da Central anuncia no barramento que nasceu.** Triggertrg_aviso_da_central_criado(AFTER INSERT emagent_inbox_items) emite central.aviso_criadocom item, kind e ref; o consumidor élib/notifications/push.handler.ts, que manda ao celular só os avisos que pedem gente — a MESMA regra do som da Central (somDoAviso): passagem para pessoa, IA sem saldo no provedor e negócio que entrou numa etapa que avisa (0440). **Trigger e não emissor em código** pelo motivo da 0148: aviso nasce em vários lugares (motor, rotas, handlers, crons), e o próximo caminho nasceria mudo. **Sem I/O no trigger** (doutrina: trigger nunca faz HTTP); o emit_eventfica num bloco que engole a falha comraise warning, porque um aviso de passagem que não grava por causa do anúncio seria o pior desfecho. Aviso de plataforma (organização nula) não emite. security definercomsearch_path fixo e as duas origens de EXECUTE revogadas (sem grant: é função de trigger). Idempotente (create or replace+drop trigger if exists). Gate: tests/invariants/aviso-da-central-no-barramento.test.ts. | | 20260927100000|0436_conversao_google_por_etapa| **Conversão do Google Ads por etapa do funil, e a venda sem valor.** Tabela novagoogle_ads_conversion_rules(uma regra por etapa: ação de conversão, rótulo, categoria, canal de entradatodos/whatsapp/outros, ligada/desligada), FK composta para crm_stages (organization_id, id)comon delete cascade, únicos por (org, etapa) e (org, event_name). event_nameé a chave do livro-razão:Etapa:para regra nova eQualifiedLeadpara a qualificação da 0402, migrada como primeira regra COM o nome de sempre (negócio já enviado não é reenviado com outro nome) e com oconfigured_atherdado.fn_marcar_configuracao_regra_google(gatilho, sóservice_role) regrava configured_atquando etapa/ação mudam ou a regra é religada — trava de retroatividade. Emad_platform_connections: google_purchase_value_mode (obrigatoriopadrão = comportamento de sempre,quando_houver, nunca), google_purchase_category, google_send_hashed_phone(padrão desligado).fn_solicitar_reenvio_conversao(uuid, uuid, text)passa a aceitarEtapa:` com a mesma exigência de retrato da qualificação. RLS sem policy e grants revogados de anon/authenticated, como a 0213. Aditiva e idempotente. |
| 20260927110000 | 0437_links_rastreaveis | Links nomeados por organização, refs nas tabelas existentes e métricas dos registros retidos. FKs compostas impedem clique em link de outro tenant. RLS sem policies, grants do navegador revogados e RPC exclusiva de service_role. Desativação preserva o histórico. |
| 20260927120000 | 0438_aviso_de_caso_ignora_conexao_arquivada | O número de uma conexão arquivada volta a poder receber os avisos. A checagem de "número da própria organização" da 0292 perguntava por channel_sessions sem filtrar archived_at, então a conexão arquivada continuava contando: quem a removeu não conseguia escolhê-la como destino do aviso (aviso_de_caso_numero_da_propria_org) — e não conseguiria nunca mais, porque a conexão que já teve agente publicado não pode ser apagada (as versões a seguram). create or replace da função inteira, mesma assinatura e o mesmo par revoke/grant; a conexão arquivada passa a ficar de fora, como já fica no índice único parcial da 0107 e em listSelectableChannels. Sem coluna, sem dado tocado, sem policy nova. Bloco da 0292 EDITADO NO LUGAR no baseline.sql (mesmo desenho das 0417/0426/0434/0435): um apêndice novo com o corpo novo divergiria do bloco antigo para quem aplica a cadeia. Gate: tests/unit/aviso-de-caso-nao-conta-conexao-arquivada.test.ts (as duas definições, cadeia × apêndice) e o caso de Postgres real acrescentado a tests/invariants/aviso-de-caso-escrita.test.ts (o número de conexão arquivada passa, o de conexão ativa continua recusado). |
| 20260927130000 | 0439_aviso_de_caso_confere_o_destino_no_envio | O aviso de caso confere no ENVIO se o destino voltou a ser número da própria organização. A guarda anti-laço da 0292 pergunta pelo número da própria organização só ao DEFINIR o aviso; a 0438 passou a ignorar a conexão ARQUIVADA (não envia nem recebe), o que era o conserto certo do número bloqueado para sempre. Mas arquivar não apaga a linha — reativar é gravar archived_at = null de novo —, então reativar uma conexão cujo número já estava gravado como destino fazia o aviso sair para um número atendido por um agente DESTA organização, sem nenhuma checagem de novo. O motor (lib/escalacao/aviso-ao-suporte.ts, passo 11b) passa a perguntar isso antes de resolver o destino e, quando é o caso, CONDEMA a entrega com o código novo destino_da_propria_organizacao em vez de enviar — grava a linha em entregas_de_aviso_de_caso, abre item na Central e audita ai.case_alert_failed, como as outras recusas definitivas. A pergunta fica no ENVIO e não na reativação de propósito: a linha é ressuscitada por três caminhos (canal oficial, pareamento do onboarding, finish do WAHA) e uma guarda por caminho nasceria desatualizada no quarto. Este arquivo só amplia o vocabulário fechado de entregas_de_aviso_de_caso.erro_codigo com o código; sem tabela, coluna, policy ou dado tocado. Bloco da 0292 EDITADO NO LUGAR no baseline.sql, como a 0438 fez. Gates: tests/unit/aviso-de-caso-confere-o-destino-no-envio.test.ts e os casos acrescentados a tests/unit/aviso-de-caso-decide.test.ts. |
| 20260927160000 | 0443_icone_da_aba | O ícone da aba pode ser um arquivo. Coluna opcional platform_branding.favicon_path (mesmo CHECK de caminho do logo, prefixo platform/, PNG ou JPG), gravada só pela rota /api/v1/marca/logo com peca=icone. Sem arquivo, a aba segue com o ícone desenhado por app/icon.tsx. Aditiva: código anterior ignora a coluna. |
| 20260928120000 | 0444_relatorio_financeiro_por_moeda | O relatório financeiro separa as moedas (#1531). fn_relatorio_financeiro somava financial_entries.amount_cents e sales.total_cents sem olhar a moeda, e a tela de Faturamento escrevia o resultado em real. Ganha a chave por_moeda: para cada moeda com movimento no período (lançamentos pagos e comandas finalizadas), os mesmos totais e listas do topo, só com as linhas daquela moeda — ticket médio com a mesma conta do topo e top 10 por moeda; {} sem movimento. A comissão e o item herdam a moeda da comanda pelo join. Aditiva: assinatura, stable, INVOKER, search_path, grants e as treze chaves antigas ficam iguais (as do topo seguem somando todas as moedas, como antes). Bloco da 0353+0356 EDITADO NO LUGAR no baseline.sql, como o próprio bloco manda. Gate: tests/invariants/relatorio-financeiro-por-moeda.test.ts. |
| 20260928132000 | 0448_empresas_pessoas_importacao | Empresas, pessoas que decidem e importação de planilha — a metade B2B do #1621 (contribuição de @renatofortal), atrás do módulo opcional MODULO_CRM_B2B, desligado por padrão (doc 68, opção b). Cinco tabelas: companies (razão social, CNPJ normalizado com único parcial por organização, dados da BrasilAPI), people (a pessoa que decide), company_people (o vínculo, com cargo), import_batches/import_rows (o lote de planilha e cada linha como veio). contacts.person_id (nullable, on delete set null): o contato é um telefone da pessoa; o WAHA segue criando contato sem pessoa. Gatilhos security invoker de mesma organização em company_people, contacts.person_id e import_rows. RLS POR OPERAÇÃO espelhando as rotas (SELECT membro; INSERT manager; UPDATE agent nas três primeiras e manager no importador; DELETE manager) — o PR original tinha for all org-flat, e o GRANT ALL ... TO authenticated do baseline deixaria um viewer escrever pelo PostgREST. Gate: tests/invariants/companies-people-rls.test.ts e as cinco em rls-isolation.test.ts. |
| 20260928132100 | 0449_lgpd_alcanca_empresas_e_pessoas | A anonimização alcança a pessoa e a linha de planilha importada. Gatilho trg_redigir_b2b_ao_anonimizar na virada de contacts.is_anonymized (molde de trg_redigir_tarefas_ao_anonimizar): import_rows do contato OU da pessoa dele perde raw_data, normalized_data e error; people troca o nome pelo rótulo e perde e-mail e anotações; company_people perde cargo, departamento e anotações. A empresa não é tocada (é da pessoa jurídica) e os OUTROS contatos da pessoa também não (telefone pode ser linha dividida; anonimizar é irreversível). Gatilho, e não passo em fn_lgpd_cascade_redact_contact: a 0477, de timestamp posterior, reescreve o corpo inteiro da função e apagaria o passo na cadeia. O export do titular (lib/lgpd/export-collector.ts) lê as três tabelas. Gate: tests/invariants/companies-people-rls.test.ts, lgpd-redact-unificado-alcanca-pelo-catalogo.test.ts e tests/unit/lgpd-exporta-o-que-redige.test.ts. |
| 20260928140000 | 0462_configuracoes_de_propostas | Configurações de propostas por organização em organizations.settings.proposals. enabled (boolean, default false — nasce desligada), default_valid_days (int, default 15), default_conditions (text nullable). A rota POST de app/api/v1/proposals usa a validade padrão quando valid_until não vem no payload. Tela de configuração em app/app/settings/tenant/proposals. |
| 20260928140100 | 0463_proposta_ai_draft_enabled | Chave na versão do agente para auto-injeção de ferramenta MCP. ai_agent_versions.proposal_ai_draft_enabled (boolean, default true) — o agente pode rascunhar proposta sozinho quando a capacidade "Propostas" está ligada na organização. Acompanha o padrão de handoff_tool_enabled: coluna de feature flag, auto-injeção via pickToolsFromMcp, propagação de valor pela cadeia de runtime do agente. Toggle na tela de edição do agente (padrão: ligado). Ferramenta MCP correspondente crm_draft_proposal (category write, role agent) rascunha proposta com drafted_by_agent_id preenchido. |
| 20260928140200 | 0464_proposta_comercial | A proposta comercial ganha tabela própria — crm_proposals/crm_proposal_items, RLS por organização, numeração sequencial por organização/ano alocada só no envio, e versão (a revisão de uma enviada cria uma v2 que herda o número). Trava de organização do item (fn_verificar_org_do_item_da_proposta), fn_proposta_aloca_numero só para service_role, bucket privado propostas com leitura por organização (split_part). Três kind novos em agent_inbox_items (vencimento, laço de retorno, promessa não cumprida) — reconstruída com a lista completa vigente então (nesta branch 0466 e 0475 ainda reconstroem depois; no baseline.sql a edição é no bloco único dela). crm_tasks.source_kind (vocabulário aberto) para a promessa virar tarefa. |
| 20260928140300 | 0465_a_proposta_nao_aponta_para_outra_organizacao | Trigger trg_crm_proposals_org_consistente: a proposta não grava lead_id, contact_id nem conversation_id de outra organização — a RLS só conferia o organization_id da própria linha. Molde de fn_verificar_org_do_item_da_proposta. Referência nula passa (o conserto da proposta enviada que sobrevive ao negócio vai tornar lead_id/contact_id anuláveis); só escrita nova é validada, então o update.sh de clone com dado legado não quebra. |
| 20260928140400 | 0466_c2_numeracao_envio_e_sobrevivencia | Onda C2 da spec de Propostas (D9+D3+D10). D9: número reemitido a dois clientes em produção (auditoria org 59914589, 19/09/2026) porque fn_proposta_aloca_numero calculava max(numero)+1 sobre linhas que existem — apagar a linha liberava o número. crm_proposal_counters (organization_id, ano, ultimo_numero) nunca deriva de linha nenhuma; a função vira volatile, um UPSERT atômico, sem laço de repetição. Semente do backfill: maior número visto em crm_proposals OU em api_audit_log (proposal.sent), o que for maior. D3: status ganha enviando — número reservado ao entrar nesse estado, enviada só quando a mensagem de WhatsApp confirma sent/delivered (nunca pela ausência de exceção); colunas message_id e ultima_falha_envio; kind novo proposta_travada no bloco único de agent_inbox_items_kind_check (cron de recuperação como o recover-stuck-messages). D10: crm_proposals.lead_id/contact_id viram nullable com on delete set null (eram not null/cascade — apagar o negócio apagava em cascata um documento já entregue ao cliente); coluna destinatario_nome grava o nome impresso no PDF; trigger trg_crm_leads_cancelar_propostas_rascunho cancela rascunho (sem valor fora do negócio) antes do delete, e propostas enviada+ sobrevivem órfãs. |
| 20260928140500 | 0467_c3_precificacao_e_rascunho_unico | Onda C3 da spec de Propostas (D5 + §5.1/5.2/5.3). pricing_status em crm_proposals (missing/catalog/manual/approved, default missing, backfill recalculado dos proprios itens); crm_proposal_items.preco_unitario_cents passa a aceitar NULL (CHECK >= 0 continua valendo para valor presente); indice unico parcial um-rascunho-aberto-por-negocio com dedupe previo (demais viram cancelada, nunca delete). |
| 20260928140600 | 0468_m0_proposta_referencia_modelo | Onda M0 da spec de Propostas (fundamento de modelos). crm_proposals ganha template_slug, template_version, template_snapshot, rendered_snapshot — nullable e aditiva, sem migração de dado (todo o histórico convive sem modelo). Snapshots ficam vazios até a Onda M5 (envio). CHECK crm_proposals_template_slug_versao_juntos_check: slug e versão nascem e morrem juntos. Gate: caso no tests/invariants/proposta-templates-isolamento-entre-organizacoes.test.ts (slug sem versão e versão sem slug → 23514). |
| 20260928140700 | 0469_c4_revisao_por_versao_e_auditoria | Onda C4 da spec de Propostas (D4). Índice de numeração passa a incluir versao — v1 enviada e v2 rascunho da mesma cadeia convivem com o mesmo numero/ano até a v2 ser enviada (só então a v1 vira substituida e sai da unicidade). Com trava pré-troca (doutrina de migrations item 8): se algum grupo já violar a chave nova, a migration PARA em vez de furar o próprio índice. |
| 20260928140800 | 0470_e1_followup_e_orcamento | Onda E1 da spec de Propostas (N2). crm_proposals.retorno_id uuid references cron_jobs(id) on delete set null — guarda QUAL retorno automático foi agendado ao enviar, para cancelá-lo se o cliente decidir (aceita/recusada) antes da data marcada. NULL = nenhum agendado (falha ao agendar não bloqueia o envio) ou já cancelado/disparado. Aditiva e idempotente (add column if not exists). |
| 20260928140900 | 0471_m0_tabela_de_modelos | Onda M0 da spec de Propostas (fundamento de modelos). proposal_templates guarda só CÓPIAS por organização (organization_id not null, spec-mãe §6.1 — a base da plataforma mora no código, MODELOS_BASE, nunca no banco com organization_id nulo). base_slug/base_version guardam de qual modelo da base a cópia veio (spec-mãe §6.1). SEM CHECK fechado de slug (os 8 modelos-piloto não estão no repositório; validação fica no Zod até o piloto ser definido). Índice único parcial (organization_id, slug) where is_active (só uma versão ativa por slug/org) + único (organization_id, slug, version). RLS em duas policies (molde de crm_proposals, 0464): SELECT aberto ao membro da org, WRITE com piso fn_role_at_least(organization_id, 'agent') — a revisão final achou a policy original sem piso de papel (qualquer viewer escrevia modelo) e corrigiu antes do merge. revoke ... from anon explícito. Gate: tests/invariants/proposta-templates-isolamento-entre-organizacoes.test.ts (mesmo slug nas duas orgs, RLS por membro, piso de papel na escrita, colisão de versão ativa). |
| 20260928141000 | 0472_m1_briefing_e_revisao | M1 (onda de modelos) — o rascunho ganha briefing e motivo de revisão. Cinco colunas nullable em crm_proposals: briefing_json (insumo estruturado do briefing, auditável), prazo_dias_uteis, pagamento, resumo_comercial (gerado na emissão, nunca digitado), version_reason (motivo da revisão). Sem CHECK — nenhuma tem vocabulário fechado na spec de 21/09 (§5.2). Sem backfill: proposta existente fica com as cinco null. |
| 20260928141100 | 0473_m3_secoes_editadas | M3 (onda de modelos) — edição manual por seção do documento. Coluna secoes_editadas jsonb nullable em crm_proposals: mapa {secaoId: textoEditado} que a rota GET/PATCH /api/v1/proposals/[id]/documento lê e escreve. Sem CHECK — chave é o id de seção do modelo em uso, vocabulário aberto. Sem backfill. |
| 20260928141200 | 0474_ia_sugere_modelo_da_proposta | N1 — a IA sugere o modelo, uma pessoa confirma (decisão do dono, 25/09/2026). Coluna template_slug_sugerido text nullable em crm_proposals: estado provisório gravado por crm_draft_proposal, limpo ao confirmar (vira template_slug) — nunca entra na constraint crm_proposals_template_slug_versao_juntos_check, que é só sobre o par confirmado. Sem CHECK, sem FK, sem backfill. |
| 20260928141300 | 0475_proposta_pronta_para_revisao | N2 — a Central avisa quando uma proposta rascunhada pela IA precisa de revisão (decisão do dono, 25/09/2026). Kind novo proposta_pronta_para_revisao no bloco único de agent_inbox_items_kind_check (última reconstrução da cadeia, repete todo o vocabulário da 0464 + o valor novo antes de other). Nasce ao rascunhar (crm_draft_proposal) e se resolve quando modelo confirmado E preço resolvido, ou ao enviar/descartar. |
| 20260928141400 | 0476_modelos_de_proposta_da_empresa | P5 (spec de 26/09) — a empresa cadastra os próprios modelos de proposta. Colunas nome/descricao nulláveis em proposal_templates (a tabela da M0 não tinha nada legível por pessoa) e a política proposal_templates_write passa de agent para manager+ (decisão #10 da spec de 21/09; nenhum código escrevia como agent). Aditiva, sem backfill. |
| 20260928141500 | 0477_redact_alcanca_crm_proposals | A cascata de anonimização (LGPD) não alcançava crm_proposals (issue #1504, achado por tests/invariants/lgpd-redact-unificado-alcanca-pelo-catalogo.test.ts). Decisão: redigir, caminho cascata, molde de orders/crm_leads — preserva número, valores, itens, datas e status; redige destinatario_nome (nome impresso no PDF, D10/0466 — recebe o rótulo, não null), briefing_json e resumo_comercial (texto sobre o negócio do cliente). template_slug_sugerido fica de fora — é slug de modelo, nunca dado do contato. Corpo de fn_lgpd_cascade_redact_contact copiado por inteiro da última definição (não de uma versão antiga) para não apagar em silêncio os passos que entregas seguintes acrescentaram (sales, campanhas, prospecção etc.); só o passo crm_proposals é novo. Nesta branch o baseline.sql recebe o corpo vigente no bloco novo da 0477, no fim do arquivo. O PDF também: todo pdf_path do contato entra em storage_redaction_queue com o bucket propostas (o passo 7 só enfileira whatsapp-media), e pdf_path, rendered_snapshot e secoes_editadas saem da linha (conserto da triagem; gate tests/invariants/proposta-anonimizar-expurga-o-pdf.test.ts). |
| 20260928150000 | 0478_notas_internas_realtime_e_visibilidade | As notas internas ganham realtime e passam a seguir a visibilidade da conversa (#1863, F1+F2). F1: conversation_notes estava FORA da publicação supabase_realtime (lista explícita no foreach do baseline) — o hook assinava, o Supabase respondia SUBSCRIBED e nenhum evento chegava, falha muda documentada no próprio docblock de app/api/v1/conversations/[id]/passagens/route.ts; a anotação só aparecia quando alguém recarregava. F2: a policy testava só fn_user_org_ids(), enquanto fn_can_view_conversation (21 usos) é quem implementa visibility_mode (all | own_and_unassigned padrão | own) — em own_and_unassigned o atendente que não abria a conversa lia as notas dela e quem perdeu a conversa continuava vendo. Molde: tenant_isolation_ai_reply_drafts_all. exists em conversations com c.organization_id = conversation_notes.organization_id (trava conversa de outra org em dado legado). O ramo or fn_is_platform_admin() da política antiga vira policy própria — o admin de plataforma não é membro de organização nenhuma. Sem coluna, sem backfill, sem dado tocado; escrita (conversation_notes_write) passa a exigir a mesma visibilidade — for all concede SELECT e anularia a leitura nova. Idempotente nas duas pontas. Gates: tests/invariants/notas-internas-respeitam-a-visibilidade-da-conversa.test.ts (leitura) e tests/invariants/notas-internas-escrita-e-admin-de-plataforma.test.ts (escrita e admin de plataforma). |
| 20260928150400 | 0482_grupos_na_inbox | (Vem depois da 0404 de propósito — comando_da_conversa herda a forma SECURITY DEFINER/sem nome da 0404 e só acrescenta is_group.) Grupos de WhatsApp na inbox, só os escolhidos. channel_session_groups (lista de permitidos por número, RLS: membro lê, só o service role escreve), contacts.kind (person/whatsapp_group), roteamento pula grupo, e fn_emit_message_event emite message.group_received para conversa de grupo — nenhum consumidor de message.received vê grupo. Índice único uq_contacts_grupo (organização + group_chat_id) impede placeholder duplicado do mesmo grupo. Spec docs/superpowers/specs/2026-09-23-grupos-na-inbox-design.md. fn_comando_da_conversa ganha p_is_group (grupo sem dono é aguardando, nunca automatico); o índice uq_contacts_grupo só é recriado quando está na definição antiga. LGPD: o gatilho da virada de is_anonymized (fn_redigir_conversas_ao_anonimizar, 0391) passa a redigir também as mensagens de GRUPO escritas por quem já é contato — casadas em metadata.group_sender pelo telefone (fn_telefone_variantes) ou pelo lid (contacts.wa_lid), lidos de OLD —, enfileirando a mídia; o participante que não é contato segue sem caminho (ver a spec). Cascata de anonimização: fn_lgpd_cascade_redact_contact parte do corpo da 0477 (propostas) e só acrescenta o passo de channel_session_groups.subject — por isso vem depois dela. |
| 20260928150200 | 0480_honorarios_modulo_oficial | Honorários, o primeiro módulo opcional no formato da ADR-0002 (contribuição de @nsbastosconsultoria, #1578). Cria só FUNÇÕES: fn_honorarios_provisionar() (sem parâmetro, security definer, só service_role executa) guarda o schema de honorarios_contratos e honorarios_parcelas, que só nascem quando o administrador da instalação instala o módulo em /admin/modulos (fn_modulo_instalar); quem não instala não carrega tabela nenhuma. fn_honorarios_parcela_pagar paga uma parcela numa transação só, com for update — um clique duplo não lança o dinheiro duas vezes — e recusa conta ou plano de contas de outra organização. Revoga execute de public, anon nas duas. RLS por operação (molde da 0464): leitura para a organização; criar, editar e apagar só manager+; parcela paga não se apaga nem se reescreve pela sessão, nem o contrato que a tem, e a parcela só aponta para contrato da própria organização. |
| 20260928150300 | 0481_indice_cooldown_de_silencio | Índice para a consulta de cooldown do gatilho de silêncio (revisão do PR de correção do loop de reinscrição). A consulta nova loadContactIdsEmCooldown (lib/followup/silence-sweep.ts) filtra followup_enrollments por (organization_id, pointer_id, contact_id, updated_at) a cada tick do cron de follow-up (1×/min); o único índice existente na tabela para (organization_id, contact_id) não cobre pointer_id. Índice composto idx_followup_enrollments_pointer_contact_cooldown, idempotente (create index if not exists), sem coluna nova. |
| 20260928184045 | 0483_anexo_na_nota_interna | A nota interna ganha anexo (#1863, F3): a análise de bloqueio media que sem coluna não havia por onde gravar o arquivo e sem bucket próprio ele caía no varredor órfão da 0435 (apagado em 1 dia). Três colunas nullable em conversation_notes (media_storage_path/media_mime/media_size_bytes bigint — o trio de messages; o kind é derivado do mime no render, coluna que espelha função é coluna que diverge) + bucket internal-media (50 MB, public=false, SEM policy em storage.objects, como o próprio whatsapp-media da 0055: escrita/leitura pelo service_role e a rota de nota, nunca o Storage API direto). LGPD: a cascata ganha o passo 6d (redige body, zera o ponteiro e enfileira o arquivo com bucket internal-media — o passo 7 só enfileira whatsapp-media, e enfileirar ali apontaria a remoção para um bucket onde o arquivo não está) e fn_enfileirar_midia_vencida ganha o passo 2b (órfãos de internal-media, mesmo carência de 1 dia, contam em v_orfas para não mudar a chave congelada da 0435). Reaplicável: add column if not exists, on conflict do update no bucket, create or replace. Gate: tests/invariants/anexo-da-nota-interna-responde-a-lgpd.test.ts. |
| 20260928200400 | 0484_busca_humana_entra_na_telemetria | A busca HUMANA do Acervo entra na telemetria (F2 da #1869). knowledge_searches só era preenchida pelo caminho do agente (search-knowledge.ts:122), então o gráfico de /app/ai/evolution — que conta linhas sem filtrar (aggregate.ts:201) — mostrava só a IA perguntando e a tela "Perguntar ao acervo" ficaria invisível na própria métrica. Colunas author_kind text not null default 'ai' check (in ('human','ai')) e author_user_id uuid (on delete set null): vocabulário da 0281, e o default é o backfill — toda linha existente veio do agente. Sem check acoplando as duas colunas, pelo mesmo motivo da 0281: quebraria o próprio set null e transformaria a saída de uma pessoa em erro de telemetria. agent_id is null NÃO serviria para distinguir (é on delete set null desde a 0181). Não mexe no INSERT do agente — search-knowledge.test.ts:127 mede as posições $1..$8 dele. |
| 20260928214705 | 0485_lgpd_alcanca_secoes_de_modulo | D8 da ADR-0002 (issue #1114): a anonimização alcança as SEÇÕES de módulo declaradas, por SQL dinâmico protegido por to_regclass. A cascata fn_lgpd_cascade_redact_contact continua sendo a função única do NÚCLEO e a exportação já tratava o 42P01 de módulo ausente (0480/#1578); o que faltava era o mecanismo GENÉRICO — sem ele, o próximo módulo com texto livre sobre a pessoa só ficaria alcançado se alguém lembrasse de reescrever uma função de ~400 linhas, e em LGPD o esquecimento é o modo de falha silencioso. Tabela nova modulo_secoes_lgpd (modulo, tabela, ligacao, colunas, colunas_rotulo) — PK composta, ligacao é predicado com $1/$2 (org e contato) que vai para execute ... using, RLS ligada sem policy e anon/authenticated/service_role sem privilégio; escrita só pela migration do módulo. O service_role fica de fora também (diferente de modulos_instalados, 0340) porque o gatilho definer de dono postgres executa o tabela e a ligacao gravados ali: com o GRANT ALL do default ACL, quem tem a service key ganharia UPDATE como postgres em qualquer tabela, api_audit_log inclusive (0258). Precedente: event_service_origins; gate: tests/invariants/adr-0002-d8-registro-fechado-ao-service-role.test.ts. Nasce VAZIA de propósito: honorários não tem texto livre sobre a pessoa (decisão escrita na própria 0480), e não se inventa dado de LGPD para módulo que não pediu. fn_lgpd_redigir_secoes_de_modulo() é gatilho after update of is_anonymized em contacts — a MESMA porta das 0174/0184/0210/0391, por onde passam os DOIS caminhos de anonimização (cascata e fn_lgpd_anonymize_contact), então não há caminho que escape por construção. security definer SEM parâmetro (D4) e revoke execute ... from public, anon, authenticated: gatilho não precisa de grant para disparar, e sem argumento de organização a função fica fora da régua de definer-membership-varredura por construção. Cada seção passa por to_regclass ANTES de qualquer comando: tabela ausente = módulo não instalado = continue, sem erro, em qualquer instalação (é literalmente o que a ADR pede e o que uma cascata nomeada por tabela não pode dar). Coluna declarada que não existe levanta modulo_secao_invalida nomeando módulo e tabela — erro ALTO, porque silenciar seria entregar ANONIMIZAÇÃO COM SUCESSO com a pessoa legível. Reaplicável (if not exists, create or replace, drop trigger if exists); o apêndice entra ANTES do bloco da VARREDURA anon (0116), que proíbe create function depois dela. Gate: tests/invariants/adr-0002-d8-secoes-de-modulo-na-cascata.test.ts (seção de mentira que só existe ali: ausente pula, presente redige, declaração errada falha alto) e tests/unit/adr-0002-funcao-provisionadora.test.ts. |
| 20260929053040 | 0488_exclusao_de_contato_sem_perder_historico | Excluir um contato que já passou por retorno automático apaga a ficha INTEIRA (issue #1862). O DELETE da rota falhava com 42501 e a auditoria saía com apagados: ["messages","conversations"] — o histórico já tinha sido apagado quando a ficha foi recusada. Duas causas, dois consertos. (1) A guarda do gatilho: fn_followup_generation_write (BEFORE em job_queue e followup_enrollment_events) recusa com 42501 qualquer escrita de quem tem auth.uid(), inclusive o DELETE que chega em cascata quando a ficha é apagada (as duas tabelas são on delete cascade de contacts). Primeira instrução do corpo: if tg_op='DELETE' and pg_trigger_depth()>1 then return old; end if; — a cascata roda DENTRO do gatilho da chave estrangeira (profundidade > 1) e passa; o DELETE direto do turno roda na profundidade 1 e continua recusado com a MESMA followup_job_internal, sem afrouxar nada. Escopo real: passa qualquer cascata de chave estrangeira, não só a da ficha — apagar inscrição (followup_enrollments), fluxo (followup_flow_pointers) ou organização também leva os eventos internos; o turno que sobra órfão falha fechado em fn_followup_job_current, e a profundidade não distingue cascata de DELETE feito por outro gatilho (hoje nenhum gatilho apaga nessas tabelas). (2) Uma transação só: deleteContactHandler (app/api/v1/contacts/_handler.ts) apagava messages, depois conversations, depois contacts em três chamadas separadas — quando a última falhava, as duas primeiras já estavam gravadas. A rota passa a chamar fn_apagar_contato_com_historico(p_contact_id, p_organization_id), security invoker (a RLS de quem chama continua valendo e organization_id é filtro do banco, argumento + where; nenhum service role), que apaga as três na ordem certa do RESTRICT da #752 e numa transação só: ou sai tudo, ou não sai nada. A pré-checagem de vínculos (calendar_appointments) segue ANTES da chamada e devolve 409 sem tocar em nada; dentro da função, um 23503 de corrida desfaz a transação inteira em vez de deixar histórico órfão. Reaplicável (create or replace nas duas funções): no baseline.sql o corpo da guarda entra EDITADO NO LUGAR no bloco da 0224 e a função nova vai no apêndice ANTES da VARREDURA anon (0116). Gates: tests/invariants/contato-com-turno-de-followup-sai-inteiro.test.ts (a ficha inteira some numa transação só, o DELETE direto do turno segue recusado para auth.uid() e a ficha com compromisso na agenda não leva nada) e tests/unit/contato-delete.test.ts (a rota chama UMA função e não apaga tabela nenhuma por conta própria). |
| 20260929060000 | 0489_followup_rls_por_operacao | RLS por operação em followup_enrollments e followup_flow_pointers (issue #1913). As duas tinham UMA policy for all com USING = membro da organização e sem papel mínimo: pelo PostgREST, qualquer membro — viewer inclusive — apagava ou reescrevia uma inscrição de follow-up ou o fluxo inteiro, e a cascata levava a trilha e os turnos agendados, sem rota nem audit. Até aqui a guarda fn_followup_generation_write barrava essa cascata por acidente quando havia turno; o PR #1912 a libera em cascata e abriria o buraco inteiro. Agora: SELECT = membro (inalterado); INSERT/UPDATE/DELETE = manager, que é o que todas as rotas de escrita de follow-up exigem (requireRole("manager")). service_role, as funções security definer e a cascata de FK não passam por RLS. Molde da 0464/0480; nenhuma função nova. Apêndice idêntico no baseline.sql, antes da VARREDURA anon. Invariante: tests/invariants/followup-rls-por-operacao.test.ts. |
| 20260929080000 | 0490_followup_trilha_e_versoes_rls_por_operacao | RLS por operação em followup_enrollment_events e followup_flow_versions (issue #1915, continuação da 0489). As duas tinham UMA policy for all com USING = membro e sem papel mínimo: pelo PostgREST um viewer apagava ou reescrevia a trilha de eventos de uma inscrição e as versões publicadas de um fluxo (base do rollback). A escrita agora espelha quem escreve pela SESSÃO, medido nas rotas: trilha — SELECT membro, INSERT manager (cancel/pause/resume/skip/snooze gravam o evento manual com requireRole("manager")), UPDATE/DELETE sem policy (append-only para a equipe); versões — SELECT membro, DELETE manager (DELETE do fluxo), INSERT/UPDATE sem policy (a versão nasce só por fn_publish_followup_flow_version, security definer chamada com service_role). Motor (service_role, pool do worker), funções security definer e cascata de FK não passam por RLS. Nenhuma função nova; apêndice idêntico no baseline.sql antes da VARREDURA anon, e as policies antigas saem do corpo do dump. Invariante: tests/invariants/followup-trilha-e-versoes-rls-por-operacao.test.ts. |
| 20260929020337 | 0486_o_playbook_da_agenda_aprende_os_dois_passos | Playbook agendamento ensina os dois passos da cadeia de agenda (#1019). O corpo semeado pela 0191 começava o meio da cadeia: mandava consultar crm_find_free_slots sem dizer de onde vem o event_type_slug que ela EXIGE, e nenhuma linha do playbook nomeava crm_list_event_types (medido: zero ocorrências em supabase/). Publica a v3 com o passo 1 (lista → slug), o passo 2 (horários com o event_type_slug da lista) e a marcação (crm_book_appointment com o starts_at devolvido), todo nome de ferramenta DENTRO de uma condição — o playbook é org-wide e não sabe quais capacidades o agente tem. Mesma forma da 0191: md5 conferido no insert, reponte SEMPRE. Catraca: tests/unit/playbook-cita-a-ferramenta.test.ts passa a cobrar crm_list_event_types. |
| 20260929115848 | 0491_dedupe_de_event_dead_atomico | O aviso event_dead não abre em dobro com dois drenos concorrentes (#880). O dedupe era pergunta e escrita separadas: lib/event-log/drain.ts consulta se já existe aviso aberto e só depois insere, e o insert … where not exists de insertInboxItem (lib/agent-engine/db/repository.ts) não tinha índice nenhum que sustentasse a condição — o cron event-log-drain e o drain-loop do worker rodam drainEventLog no mesmo instante e os dois leem "não existe" antes de qualquer escrita. Índice único parcial agent_inbox_event_dead_aberto_unico (organization_id, kind, title) where status = 'open' and kind = 'event_dead': a chave leva o TÍTULO porque as duas famílias do event_dead (a da IA que deixou de responder e a de mídia/automação, lib/event-log/aviso-de-evento-morto.ts) precisam conviver abertas na mesma organização, e o predicado é PARCIAL porque status na chave guardaria uma linha resolvida para sempre e mataria a reabertura. Escopo só event_dead — os outros dedupes desta tabela são por ref e querem várias linhas abertas com o mesmo título, uma por conversa ou por lead. Prévia resolve as cópias abertas repetidas (só a mais antiga fica aberta; nunca delete, régua da 0064). insertInboxItem e o dreno passam a tratar 23505 como "já havia um aviso aberto" — devolve null e não loga erro —, e a reabertura para um slot que já tem um aberto igual volta 409 em vez de 500 (app/api/v1/ai/inbox/[id]/route.ts). Idempotente nas duas pontas. Gate: tests/unit/aviso-event-dead-concorrente-abre-uma-vez.test.ts. |
| 20260930010300 | 0494_lgpd_cascata_banco_alcanca_notas_itens | A cascata do BANCO alcança lead_notes, ai_agent_runs.tool_calls, lead_state e contacts.social_identity (issue #1964, follow-up do #1958). O gatilho da virada de is_anonymized (desenho da 0391, a porta que os DOIS caminhos de anonimização cruzam) passou a redigir também: lead_notes.headline/body (e embedding) → (anonimizado), ai_agent_runs.tool_calls → redação que PRESERVA o nome da ferramenta e apaga argumentos/resultados (espelha redigirToolCalls/serialize.ts, com redacted = true em todo passo), lead_state.next_action → null e qualification → {}, e contacts.social_identity → null (o índice único parcial where social_identity is not null torna anular seguro). Filtra organização e contato em todo passo; idempotente — guard no WHERE repete o marcador de "já redigido" da app para a varredura diária não reescrever o que já está anonimizado. fn_lgpd_redigir_tool_calls(jsonb) é função nova immutable revogada de anon/authenticated (único chamador é o gatilho, security definer de dono postgres). Sem cura retroativa no corpo (a varredura diária da app já cura o que veio antes); apêndice idempotente no baseline.sql antes da VARREDURA anon. Gate: tests/invariants/lgpd-cascata-do-banco-alcanca-notas-itens.test.ts. |
| 20260930120000 | 0495_janela_de_resposta_separada | A janela de RESPOSTA sai da janela de DISPARO (#1983, de @suporteubere99-coder). As duas coisas eram regidas por UM par de horas em channel_knobs (window_start_hour/window_end_hour, da 0010), então abrir o atendimento para 24h abria junto o disparo em massa, a prospecção e o followup_turn (retomada de conversa parada). Duas colunas nullable, resposta_start_hour/resposta_end_hour, sem DEFAULT: NULL herda a janela de disparo, coluna a coluna (loadChannelKnobs em lib/agent-engine/pacing/store.ts e effectiveKnobs em lib/ai/pacing-knobs.ts, a mesma regra que a ficha Anti-ban mostra no placeholder) — quem só atualiza não muda de operação. CHECK 0..23 / 1..24 com drop … if exists antes do add. Um bloco do guardado renomeia reengajar_start_hour/reengajar_end_hour para o nome novo onde a primeira versão do PR (nome antigo) já tinha sido aplicada. A separação vive em PacingInput.resposta (omitido = disparo: todo chamador fora do turno de resposta fica na janela de disparo); só inbound_turn/case_reply_turn passam true (eTurnoDeResposta em inbound-turn.ts), inclusive na pergunta do roteiro que sai no mesmo turno. nextDayOpen segue na janela de disparo: cap diário é proteção anti-ban. Cap, warm-up e throttle continuam para os dois lados. Sem função nova. Gates: lib/agent-engine/pacing/janela-de-resposta.test.ts e tests/unit/protecao-de-envio-aceita-data-em-branco.test.ts (0/24 aceito, 22/7 recusado com 422). |
| 20260930130000 | 0496_org_operante_e_suspensao_tipada | A suspensão que suspende (spec cobrança do revendedor, PR 1). Suspender só tirava a pessoa da tela: a rota fazia leitura, UPDATE e event_log sem await em três passos soltos, nada parava jobs pending nem mensagens queued, e status era gravável pelo PostgREST por qualquer platform admin (orgs_write_platform_admin aceita fn_is_platform_admin(), que ignora o scope — um support_readonly reativava uma suspensa). (A) organizations.suspended_kind (administrativa/cobranca), backfill antes do CHECK, SEM CHECK de coerência com status (o lgpd-redact-worker troca para redacted sem limpar o tipo; regra de leitura em lib/organizacao/operante.ts) e fn_org_operante(uuid), a régua SQL do predicado. (B) Gatilho trg_organizacao_estado_so_pelo_servidor (invoker, molde fn_meet_stamp): authenticated/anon não inserem organização nem mudam status, suspended_* ou created_by (42501). (C0) fn_org_parada_descarta_fila (invoker, EXECUTE revogado até de service_role; só as duas funções de estado a chamam): jobs pending → failed/org_nao_operante e, na mesma CTE, o que dependia deles — rascunho approved → failed/org_suspensa, link do Meet → state failed + aviso na Central (fn_meet_notice) — e o evento turn_discarded para a inscrição de follow-up parada num nó action. (C) fn_suspender_organizacao (definer, uma transação, for update): chama a C0, mensagens queued → failed/org_suspensa, tenant.suspended no event_log na mesma transação; a administrativa prevalece. (D) kind org_reativada no bloco único de agent_inbox_items_kind_check (lista completa do baseline, kind-check-migration-x-baseline). (E) fn_reativar_organizacao (definer): exige o tipo (suspensão nula vale como administrativa), zera a suspensão, falha pending remanescente e abre UM item org_reativada com a contagem de conversas (fora grupos) com mensagem durante a suspensão — nada é reprocessado. (F) fn_claim_due_followup_enrollments (definição vigente da 0308 + exists de org active na CTE orgs): o motor de follow-up não reclama inscrição de org parada nem toca o lease; na reativação ela RETOMA, e a de nó action com turn_discarded ganha turno novo em vez de esgotar o dead-man. (G) fn_followup_generation_write (definição vigente da 0488): recusa turn_discarded vindo da sessão (auth.uid(), 42501 followup_step_internal) — só o servidor grava o evento que faz o motor enfileirar. Nenhuma das duas cita cobranca_assinaturas (PR 2). EXECUTE revogado de public, anon e authenticated; só service_role. Apêndice antes da VARREDURA anon (seção D editada no lugar). Gate: tests/invariants/org-suspensa.test.ts, tests/invariants/followup-org-suspensa.test.ts e o par organizations.suspended_kind × TIPOS_DE_SUSPENSAO em tests/invariants/vocabulario-banco-x-typescript.test.ts. |
| 20260930140000 | 0497_lgpd_anonimizar_apaga_transcricao | Anonimizar apaga também a TRANSCRIÇÃO da mídia (messages.media_derived_text). Achado na triagem do #1988 (@AlecLimaDev). A coluna (0058, gravada pelo media-derive-worker) não era zerada por nenhum caminho: o body do áudio virava [mensagem anonimizada] e a transcrição continuava legível para o agente. Uma linha media_derived_text = null em cada UPDATE de messages de fn_redigir_conversas_ao_anonimizar (corpo da 0494, 2 passadas) e de fn_lgpd_cascade_redact_contact (corpo da 0483, passo 3) — nenhuma outra linha muda. Cura idempotente das mensagens já anonimizadas pelo marcador do body (alcança grupos e poupa quem voltou). A varredura diária de lib/lgpd/cascata.ts ganha o mesmo passo, e fecha a corrida do worker que grava o texto depois da anonimização. |
| 20260930150000 | 0498_debounce_rajada_configuravel | A janela de rajada do WhatsApp vira campo da versão do agente (#1856). A janela que junta mensagens do MESMO contato numa resposta só era só a env do worker INBOUND_DEBOUNCE_MS (global, invisível na tela, exige VPS). Coluna ai_agent_versions.inbound_debounce_ms (integer, sem DEFAULT): NULL = usa a env da instalação (regressão zero para quem só atualiza), 0 desliga a coalescência, 1..60000 define a janela em ms com TETO de 60s — o teto é reforçado no leitor do worker (debounceEfetivo em lib/agent-engine/edge/crm/debounce.ts) e com CHECK 0..60000 no banco (par drop/add pra reaplicar). O drain lê a config do agente da conversa (loadConversationAgentConfig) e usa o valor do agente no lugar do literal. Gate: lib/agent-engine/edge/crm/debounce.test.ts (default = env, campo = valor, teto clamp no limite). |
| 20260930160000 | 0499_atraso_humano_por_conexao | O atraso humano vira configuração por conexão (issue #653). Os quatro números que governam o atraso ANTES da primeira bolha (atraso_notar_ms/ms_por_caractere/atraso_minimo_ms/atraso_maximo_ms) saem do literal em atraso-humano.ts e passam a ser 4 colunas NULL em channel_knobs (a MESMA tabela dos knobs de pacing por conexão — 0010 e 0495; nada de tabela nova). NULL = default conservador em defaults.ts (900/22/1200/7500 — os valores de antes: regressão zero). O jitter entre bolhas (1200 + rand*800 em inbound-turn.ts) NÃO ganha coluna: ele já é throttle_ms + jitter_max_ms (defaults 1200/800) e passa apenas a LER os knobs da conexão em vez do literal. CHECK de sanidade (atraso_maximo_ms >= atraso_minimo_ms, teto intervalMaxMs); exposição na ficha Anti-ban via POST/GET /api/v1/ai/pacing. Gate: tests/unit/atraso-humano-knobs.test.ts. |