Pular para o conteúdo

Data Model V1 — Tactflow Conversation Runtime

Status: corrigir · Escopo: Schema PostgreSQL do núcleo de conversa (migrations 0001 a 0022) · Atualizado em: 2026-08-04

Schema canônico v1 (PostgreSQL / Neon). Deriva do blueprint.md §11 e resolve as pendências do schema-decisions.md.

Estágio do processo: Schema v0 (ver docs/parked/process/solution-design-process.md). DDL é proposta de referência — a migração executável (Drizzle/SQL) sai no scaffold de apps/.


#ConvençãoDecisãoOrigem
C1PKuuid v7 (ordenável no tempo, sem coordenação)schema-decisions 7.1
C2tenant_idPresente em toda tabela operacional; índices compostos (tenant_id, …)7.2
C3Enumstext + CHECK (evolução sem ALTER TYPE)7.3
C4Timestampstimestamptz, sempre UTC; created_at/updated_at7.6
C5JSONBPara schema-on-read (payload, context, variables, metadata, kpis); colunas para o que se filtra7.4
C6ParticionamentoRANGE mensal por created_at nas tabelas append-only de alto volume7.5
C7Soft-deleteEvitado em PII (conflita com erasure); deleted_at só onde há sentido operacional7.7
C8FKSempre com ON DELETE explícito; RESTRICT por padrão, CASCADE só em filhos puros
C9tenant_id vs unit_idtenant_id = chave de isolamento (em tudo, sempre). unit_id = dono operacional — denormalizado (NOT NULL) nas filhas de conversation e em learning; nullable onde a unit pode não estar resolvida; ausente nas entidades de identidade (cross-unit por design)§4.9
C10environment (proveniência)production/simulation/shadow em contacts, conversations + denorm em response_plans/outcome_signals/llm_calls. Sintético nunca contamina produção (analytics, memória, billing)§17.5

São dois conceitos distintos, não redundância:

  • tenant_id é a fronteira de segurança/multi-tenancy → presente em toda linha (RLS/filtro sem multi-hop join). Mesmo tenant com 1 unit continua sendo a conta.
  • unit_id é o dono operacional → presente onde há filial responsável, denormalizado das conversas para permitir permissão por unidade, analytics e otimização por segmento (§17.4) sem join.
EscopoEntidades
tenant + unit (NOT NULL)channel_endpoints, conversations, workflow_instances, messages, message_bursts, response_plans, response_fragments, outbound_deliveries, conversation_summaries, conversation_scores
tenant + unit nullablepolicy_profiles/pipeline_definitions (NULL = default do tenant), tasks, message_templates, knowledge_sources/knowledge_chunks, consent_events (por scope), assignees, channel_payloads/outcome_signals/timeline_events/runtime_logs/llm_calls (NULL = pré-resolução ou sem conversa)
tenant only (proposital)contacts, channel_identities, contact_profile_factsidentidade é cross-unit: a mesma pessoa fala com várias units da marca; amarrar a uma unit fragmentaria o contato. Visibilidade entre units = policy_profiles.profile_sharing
tenant only (config)eval_cases, eval_runs, pipeline_stages (herda unit do pai)

unit_id denormalizado é imutável (conversa não troca de unit) — preenchido no insert a partir do pai; sem risco de drift.

Proveniência: produção vs. simulação (C10, §17.5)

Seção intitulada “Proveniência: produção vs. simulação (C10, §17.5)”

Dado sintético (simulação/self-play) não pode contaminar produção. Flag environmentproduction/simulation/shadow:

OndePapel
contacts.environment, conversations.environmentFonte da verdade — lead/conversa sintéticos isolados
response_plans, outcome_signals, llm_calls (denorm)Filtro de otimização e separação de custo (sim vs. real)
simulation_personasFixture da liga — fora do core de produção
conversations.simulation_persona_idLiga a conversa simulada à persona que a gerou

Invariantes (app-enforced):

  • Analytics/otimização real filtram environment = 'production' por default.
  • contact_profile_facts nunca escreve a partir de conversa simulation.
  • Billing/uso real exclui environment <> 'production'.
  • conversations.endpoint_id é nullable porque simulação não tem WABA real.

environment (coluna) ≠ dev/staging (Neon branch). A coluna é proveniência de dado que coexiste no mesmo store (sim e real comparáveis numa query — calibração, cold-start, shadow). Neon branches resolvem isolamento de infra (dev/CI/preview/migração) e batches de simulação efêmeros (copy-on-write traz a config real do tenant). Padrão híbrido: rodar batch num branch e exportar agregados de volta com environment='simulation'. São complementares — a coluna continua necessária para shadow e para o learning. Ver blueprint §17.5 e §19 Q32.

Export branch → prod: Neon não tem merge de dados nativo; usar postgres_fdw (ou dump/ETL) com WHERE environment='simulation'. PK uuid v7 (C1) ⇒ sem colisão de ID. Mover a fatia de aprendizado (outcome_signals + treatments de response_plans + conversation_scores); não importar messages/channel_payloads brutas. A coluna environment é o filtro que torna isso possível — por isso não é redundante com a topologia de branch.

CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid (fallback)
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector (RAG)
-- uuid v7: nativo no PG18 (uuidv7()); em PG16/17 usar pg_uuidv7 ou gerar na app.
-- As DDLs abaixo assumem uma função uuid_generate_v7() disponível.
-- Helper portátil (PG16/17) até uuidv7() nativo. Substituível por pg_uuidv7.
-- Base = uuid aleatório de 16 bytes; sobrepõe o timestamp ms nos 6 primeiros bytes
-- e fixa os bits de versão (7) e variante. Validado em PG17 (Neon).
CREATE OR REPLACE FUNCTION uuid_generate_v7() RETURNS uuid AS $$
SELECT encode(
set_bit(set_bit(
overlay(uuid_send(gen_random_uuid())
PLACING substring(int8send(floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint) FROM 3) FROM 1 FOR 6),
52, 1), 53, 1), 'hex')::uuid;
$$ LANGUAGE sql VOLATILE;

ItemDecisãoJustificativa
bursts ↔ messages (1.1)messages.burst_id FK nullableSem join table; uma mensagem pertence a no máx. 1 burst
fragments → delivery (1.2)fragment → message outboundoutbound_deliveries.message_idCada bloco enviado é uma message real; outbox referencia a message
messages.direction (1.4)Uma tabela + direction enum (inbound/outbound)Histórico unificado, ordenação natural
channel_endpoints vs identities (2.1)endpoint key = phone_number_id (WABA); identity key = wa_idResolução de inbound determinística
profile cross-unit (2.2)policy_profiles.profile_sharingtenant|unit; default tenantFranquia juridicamente independente pode isolar
consent cross-unit (2.3)consent_events granular por (contact, scope), scopetenant|unitOpt-out pode valer rede ou só filial
idempotência envio (4.1)send_idempotency_key = response_plan_id:fragment_index, UNIQUE em outbound_deliveries (não-particionada)Sem mensagem duplicada ao lead
idempotência inbounddedup em tabela dedicada idempotency_keys (não-particionada); UNIQUE em tabela particionada (messages/channel_payloads) é impossível sem a chave de partiçãoValidado no Neon — ver §10b
erasure (6.1)Hard-delete de PII; timeline_events de auditoria sem PIILGPD direito ao esquecimento
cripto PII (6.2)Envelope encryption; contacts.pii_sensitive marca colunas sensíveisChave fora do Postgres
particionamento (7.5)RANGE mensal: messages, timeline_events, runtime_logs, llm_calls, channel_payloads, outcome_signalsRetenção por DROP PARTITION
vector store (7b.1/7b.2)pgvector no Neon; índice HNSW + filtro (tenant_id, unit_id)Postgres-first

Nota sobre FK e particionamento: tabelas particionadas exigem PRIMARY KEY (id, created_at). outbound_deliveries.message_id referencia messages sem FK no banco (app-enforced — message criada na mesma transação), evitando chave composta propagada.


CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','suspended','archived')),
settings jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE units (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
name text NOT NULL,
slug text NOT NULL,
timezone text NOT NULL DEFAULT 'America/Sao_Paulo',
business_hours jsonb NOT NULL DEFAULT '{}',
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','suspended','archived')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, slug)
);
CREATE INDEX idx_units_tenant ON units (tenant_id);
CREATE TABLE assignees (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid REFERENCES units(id) ON DELETE SET NULL,
user_id uuid, -- fronteira p/ auth futuro (sem FK em v1)
display_name text NOT NULL,
role text NOT NULL DEFAULT 'agent'
CHECK (role IN ('agent','closer','reception','manager','bot')),
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','inactive')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_assignees_tenant ON assignees (tenant_id, unit_id);
CREATE TABLE contacts (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
display_name text,
primary_phone text, -- E.164; criptografar se pii_sensitive
consent_state text NOT NULL DEFAULT 'unknown'
CHECK (consent_state IN ('opted_in','opted_out','unknown')),
environment text NOT NULL DEFAULT 'production'
CHECK (environment IN ('production','simulation','shadow')), -- §17.5
pii_sensitive boolean NOT NULL DEFAULT true,
attributes jsonb NOT NULL DEFAULT '{}',
erased_at timestamptz, -- erasure LGPD (PII hard-deleted)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_contacts_tenant ON contacts (tenant_id);
CREATE INDEX idx_contacts_phone ON contacts (tenant_id, primary_phone);
-- Endpoint do NEGÓCIO (número/conta WABA da unit). Resolve tenant+unit no inbound.
CREATE TABLE channel_endpoints (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
channel text NOT NULL CHECK (channel IN ('whatsapp','sms','email','instagram','webchat')),
provider text NOT NULL DEFAULT 'meta',
phone_number_id text, -- WABA: chave de resolução do inbound
external_address text, -- número/conta/handle exibível
messaging_tier text DEFAULT 'standard', -- rate/messaging tier (Meta)
credentials_ref text, -- ponteiro p/ secrets store (NÃO o segredo)
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','disabled')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (channel, phone_number_id)
);
CREATE INDEX idx_endpoints_tenant_unit ON channel_endpoints (tenant_id, unit_id);
-- Identidade do LEAD num canal (wa_id, telefone) → contato (escopo tenant).
CREATE TABLE channel_identities (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
channel text NOT NULL CHECK (channel IN ('whatsapp','sms','email','instagram','webchat')),
external_id text NOT NULL, -- wa_id / telefone / email
verified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, channel, external_id)
);
CREATE INDEX idx_identities_contact ON channel_identities (contact_id);

CREATE TABLE policy_profiles (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid REFERENCES units(id) ON DELETE CASCADE, -- NULL = default do tenant
name text NOT NULL,
disclosure_level text NOT NULL DEFAULT 'brand_assistant'
CHECK (disclosure_level IN ('none','brand_assistant','explicit_ai')),
profile_sharing text NOT NULL DEFAULT 'tenant'
CHECK (profile_sharing IN ('tenant','unit')),
rules jsonb NOT NULL DEFAULT '{}', -- limites, playbooks, persona/tom
ai_limits jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_policy_tenant_unit ON policy_profiles (tenant_id, unit_id);
CREATE TABLE pipeline_definitions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid REFERENCES units(id) ON DELETE CASCADE,
name text NOT NULL,
lens text, -- rótulo de GTM (ex.: 'sales'); NÃO altera o core
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_pipeline_tenant ON pipeline_definitions (tenant_id);
CREATE TABLE pipeline_stages (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
pipeline_id uuid NOT NULL REFERENCES pipeline_definitions(id) ON DELETE CASCADE,
position int NOT NULL,
name text NOT NULL,
objective text,
operation_mode text NOT NULL DEFAULT 'autonomous'
CHECK (operation_mode IN ('autonomous','hybrid','copilot','human_only')),
ai_limits jsonb NOT NULL DEFAULT '{}',
handoff_triggers jsonb NOT NULL DEFAULT '{}',
kpis jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (pipeline_id, position)
);
CREATE INDEX idx_stages_pipeline ON pipeline_stages (pipeline_id);

CREATE TABLE conversations (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE RESTRICT,
endpoint_id uuid REFERENCES channel_endpoints(id) ON DELETE RESTRICT, -- nullable: simulação
channel text NOT NULL,
environment text NOT NULL DEFAULT 'production'
CHECK (environment IN ('production','simulation','shadow')), -- §17.5
simulation_persona_id uuid, -- FK p/ simulation_personas (NULL em produção)
runtime_state text NOT NULL DEFAULT 'idle'
CHECK (runtime_state IN ('idle','collecting_burst','decision_pending',
'response_scheduled','responding','awaiting_user_reply','handoff_pending',
'human_active','send_failed','reengage_pending','template_required',
'paused','closed')),
pacing_state text DEFAULT 'listening'
CHECK (pacing_state IN ('listening','waiting_for_more_input',
'deliberate_delay_active','scheduled_to_send','interrupted_before_send',
'cooldown','dormant')), -- §13 silence/pacing
window_state text NOT NULL DEFAULT 'closed'
CHECK (window_state IN ('open','closed')), -- cache; verdade = expires_at
messaging_window_expires_at timestamptz,
conversation_class text,
last_message_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_conv_tenant_unit ON conversations (tenant_id, unit_id);
CREATE INDEX idx_conv_contact ON conversations (contact_id);
CREATE INDEX idx_conv_state ON conversations (tenant_id, runtime_state);
CREATE INDEX idx_conv_env ON conversations (tenant_id, environment);
-- Canal web booking (E32.T11): no encerramento pós-booking (`end_conversation` ou idle ~3min),
-- `conversations.close()` faz UPDATE runtime_state='closed' e o browser rotaciona o `session_id`
-- (`closeWebConversationWithNewSession`) para iniciar uma conversa nova no próximo contato.
-- 'closed' aqui é fim-de-thread normal do wedge, não só terminal de handoff/abandono.
CREATE TABLE workflow_instances (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
pipeline_id uuid NOT NULL REFERENCES pipeline_definitions(id) ON DELETE RESTRICT,
current_stage_id uuid REFERENCES pipeline_stages(id) ON DELETE SET NULL,
operation_mode text NOT NULL DEFAULT 'autonomous'
CHECK (operation_mode IN ('autonomous','hybrid','copilot','human_only')),
assignee_id uuid REFERENCES assignees(id) ON DELETE SET NULL,
status text NOT NULL DEFAULT 'open'
CHECK (status IN ('open','won','lost','abandoned','closed')),
entered_stage_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_wf_conversation ON workflow_instances (conversation_id);
CREATE INDEX idx_wf_assignee ON workflow_instances (assignee_id);
CREATE TABLE message_bursts (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid NOT NULL REFERENCES units(id) ON DELETE RESTRICT, -- denorm da conversa
conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'collecting'
CHECK (status IN ('collecting','sealed','superseded')),
sealed_at timestamptz,
message_count int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_bursts_conversation ON message_bursts (conversation_id);
-- PARTICIONADA por mês (created_at)
CREATE TABLE messages (
id uuid NOT NULL DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL,
unit_id uuid NOT NULL, -- denorm da conversa (sem FK: particionada)
conversation_id uuid NOT NULL,
burst_id uuid, -- nullable: outbound/sem burst
direction text NOT NULL CHECK (direction IN ('inbound','outbound')),
media_type text NOT NULL DEFAULT 'text'
CHECK (media_type IN ('text','audio','image','document','location','interactive')),
body text,
transcript text, -- conteúdo entendido (áudio/visão) §4.13
media_ref text, -- media_id / r2_key do bruto
external_id text, -- wamid (inbound)
status text, -- delivery status (outbound)
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_messages_conv ON messages (conversation_id, created_at DESC);
CREATE INDEX idx_messages_unit ON messages (tenant_id, unit_id, created_at DESC);
CREATE INDEX idx_messages_burst ON messages (burst_id);
-- NÃO-único: tabela particionada não permite UNIQUE sem a chave de partição (created_at),
-- e incluir created_at quebraria a idempotência (mesmo wamid em mês diferente passaria).
-- Dedup real de inbound vive em `idempotency_keys` (não-particionada). Ver §5 + §1.
CREATE INDEX idx_messages_external ON messages (tenant_id, external_id)
WHERE direction = 'inbound' AND external_id IS NOT NULL;
CREATE TABLE response_plans (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid NOT NULL REFERENCES units(id) ON DELETE RESTRICT, -- denorm da conversa
conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
burst_id uuid REFERENCES message_bursts(id) ON DELETE SET NULL,
plan_trigger text NOT NULL DEFAULT 'reactive'
CHECK (plan_trigger IN ('reactive','reengagement','handoff_return','system')),
operation_mode text NOT NULL DEFAULT 'autonomous',
delivery_mode text NOT NULL DEFAULT 'free_form'
CHECK (delivery_mode IN ('free_form','template')),
delivery_format text NOT NULL DEFAULT 'text'
CHECK (delivery_format IN ('text','interactive','template','payment')),
fragment_count int NOT NULL DEFAULT 1,
scheduled_send_at timestamptz,
grounded_on jsonb, -- refs de knowledge_chunks §5.12
confidence numeric(4,3), -- 0..1 → confidence gate §4.14
memory_commit boolean NOT NULL DEFAULT false,
memory_commit_reason text,
variant_id text,
prompt_version text,
policy_profile_id uuid REFERENCES policy_profiles(id) ON DELETE SET NULL,
environment text NOT NULL DEFAULT 'production'
CHECK (environment IN ('production','simulation','shadow')), -- denorm §17.5
-- Causalidade: sem isto, treatment logado é confundido (§17.4 Princípio 3)
exploration boolean NOT NULL DEFAULT false, -- ação randomizada (explore) vs greedy
propensity numeric(5,4), -- P(ação | policy) p/ IPW / off-policy
-- Parâmetros de controle = TREATMENTS capturados p/ otimização futura (§ Control params)
burst_wait_ms int, -- quanto esperou agregando antes de selar
response_delay_ms int, -- pacing/humanização escolhido antes do envio
verbosity text, -- hint contextual de extensão: ack/short/normal/long (§17.4)
confidence_threshold numeric(4,3), -- limiar aplicado na decisão de escalar
escalated boolean NOT NULL DEFAULT false, -- este ciclo escalou p/ humano?
-- Específicos de re-engajamento (NULL quando plan_trigger='reactive')
followup_attempt_no int, -- nº da tentativa de reativação (1ª, 2ª…)
dormancy_duration_ms bigint, -- silêncio acumulado até este nudge
window_state_at_decision text, -- open/closed no momento da decisão
status text NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned','generating','sent','cancelled','failed')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_plans_conversation ON response_plans (conversation_id, created_at DESC);
CREATE INDEX idx_plans_reengagement ON response_plans (tenant_id, plan_trigger, created_at DESC)
WHERE plan_trigger = 'reengagement';
CREATE TABLE response_fragments (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid NOT NULL REFERENCES units(id) ON DELETE RESTRICT, -- denorm do plano
response_plan_id uuid NOT NULL REFERENCES response_plans(id) ON DELETE CASCADE,
fragment_index int NOT NULL,
content text, -- texto gerado (ou NULL se gerado no envio §19 Q3)
payload jsonb, -- botões/listas/template vars
message_id uuid, -- message outbound gerada (app-enforced)
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (response_plan_id, fragment_index)
);
CREATE TABLE tasks (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid REFERENCES units(id) ON DELETE SET NULL,
conversation_id uuid REFERENCES conversations(id) ON DELETE CASCADE,
assignee_id uuid REFERENCES assignees(id) ON DELETE SET NULL,
type text NOT NULL, -- trabalho humano/operacional (NÃO agendamento técnico)
title text NOT NULL,
status text NOT NULL DEFAULT 'open'
CHECK (status IN ('open','in_progress','done','cancelled')),
due_at timestamptz,
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id, status);

CREATE TABLE consent_events (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
scope text NOT NULL DEFAULT 'tenant' CHECK (scope IN ('tenant','unit')),
unit_id uuid REFERENCES units(id) ON DELETE CASCADE, -- obrigatório se scope='unit'
event text NOT NULL CHECK (event IN ('opt_in','opt_out')),
source text,
legal_basis text,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (scope = 'tenant' OR unit_id IS NOT NULL)
);
CREATE INDEX idx_consent_contact ON consent_events (contact_id, created_at DESC);
CREATE TABLE message_templates (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid REFERENCES units(id) ON DELETE CASCADE,
provider_template_name text NOT NULL,
category text NOT NULL CHECK (category IN ('utility','marketing','authentication')),
language text NOT NULL DEFAULT 'pt_BR',
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('approved','pending','rejected','disabled')),
variables jsonb NOT NULL DEFAULT '[]',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, provider_template_name, language)
);
-- PARTICIONADA por mês — índice de payloads de canal (raw seletivo no R2)
CREATE TABLE channel_payloads (
id uuid NOT NULL DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL,
unit_id uuid, -- denorm da conversa (nullable: inbound pré-resolução)
conversation_id uuid,
trace_id uuid,
channel text NOT NULL,
direction text NOT NULL CHECK (direction IN ('inbound','outbound')),
external_id text,
idempotency_key text,
http_status int,
provider_error_code text,
provider_trace_id text,
payload_hash text,
raw_stored boolean NOT NULL DEFAULT false,
r2_key text,
retain_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_payloads_conv ON channel_payloads (conversation_id, created_at DESC);
CREATE INDEX idx_payloads_trace ON channel_payloads (trace_id);
-- NÃO-único (mesma razão de `messages`): dedup real em `idempotency_keys`.
CREATE INDEX idx_payloads_idem ON channel_payloads (tenant_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
-- Dedup global de idempotência — NÃO-particionada (unicidade real entre partições/meses).
-- Insert ON CONFLICT DO NOTHING no ingest; 0 linhas afetadas ⇒ duplicata, descarta.
-- scope: 'inbound_message' (wamid), 'channel_payload', etc. TTL via delete por first_seen_at.
CREATE TABLE idempotency_keys (
tenant_id uuid NOT NULL,
scope text NOT NULL,
key text NOT NULL,
first_seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, scope, key)
);
CREATE TABLE outbound_deliveries (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid NOT NULL REFERENCES units(id) ON DELETE RESTRICT, -- denorm da conversa
conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
trace_id uuid,
message_id uuid, -- app-enforced (messages é particionada)
send_idempotency_key text NOT NULL, -- response_plan_id:fragment_index
delivery_mode text NOT NULL DEFAULT 'free_form'
CHECK (delivery_mode IN ('free_form','template')),
template_id uuid REFERENCES message_templates(id) ON DELETE SET NULL,
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued','sending','sent','delivered','read','failed','dead')),
attempts int NOT NULL DEFAULT 0,
max_attempts int NOT NULL DEFAULT 5,
next_retry_at timestamptz,
last_error text,
provider_trace_id text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (send_idempotency_key),
CHECK (delivery_mode <> 'template' OR template_id IS NOT NULL)
);
CREATE INDEX idx_outbox_retry ON outbound_deliveries (status, next_retry_at)
WHERE status IN ('queued','failed');

CREATE TABLE conversation_summaries (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid NOT NULL REFERENCES units(id) ON DELETE RESTRICT, -- denorm da conversa
conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
summary text NOT NULL,
key_events jsonb NOT NULL DEFAULT '[]',
objections jsonb NOT NULL DEFAULT '[]',
intents jsonb NOT NULL DEFAULT '[]',
version int NOT NULL DEFAULT 1, -- append leve: histórico de resumo
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (conversation_id, version)
);
CREATE INDEX idx_summaries_conv ON conversation_summaries (conversation_id, version DESC);
CREATE TABLE contact_profile_facts (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
fact_key text NOT NULL, -- namespace aberto: identity.name, vehicle, funnel.stage…
fact_value jsonb NOT NULL,
confidence numeric(4,3) NOT NULL DEFAULT 0.5,
source text NOT NULL DEFAULT 'inferred'
CHECK (source IN ('declared','inferred','imported')),
updated_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (contact_id, fact_key)
);
CREATE INDEX idx_facts_contact ON contact_profile_facts (contact_id);

Policy/procedural memory = policy_profiles (§3). Working memory = conversations + response_plans + Durable Objects (não tem tabela própria — é estado vivo; não há Redis, ver ADR 0001).


CREATE TABLE knowledge_sources (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid REFERENCES units(id) ON DELETE CASCADE,
type text NOT NULL CHECK (type IN ('catalog','faq','policy','doc')),
uri text,
version int NOT NULL DEFAULT 1,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','indexing','stale','archived')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_ksources_tenant ON knowledge_sources (tenant_id, unit_id);
CREATE TABLE knowledge_chunks (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
source_id uuid NOT NULL REFERENCES knowledge_sources(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid REFERENCES units(id) ON DELETE CASCADE,
content text NOT NULL,
embedding vector(1536), -- dim conforme modelo de embedding (Q21)
metadata jsonb NOT NULL DEFAULT '{}',
source_version int NOT NULL DEFAULT 1, -- frescor: reindex incremental
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_chunks_source ON knowledge_chunks (source_id);
CREATE INDEX idx_chunks_tenant ON knowledge_chunks (tenant_id, unit_id);
-- Índice vetorial HNSW (cosine). Filtro tenant/unit antes do ANN via pre-filter na query.
CREATE INDEX idx_chunks_embedding ON knowledge_chunks
USING hnsw (embedding vector_cosine_ops);

CREATE TABLE conversation_scores (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
unit_id uuid NOT NULL REFERENCES units(id) ON DELETE RESTRICT, -- denorm da conversa
conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
score numeric(4,3),
dimensions jsonb NOT NULL DEFAULT '{}', -- rubrica multi-dimensional
source text NOT NULL CHECK (source IN ('heuristic','judge','human')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_scores_conv ON conversation_scores (conversation_id);
CREATE TABLE eval_cases (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
name text NOT NULL,
input jsonb NOT NULL,
expected jsonb,
rubric jsonb NOT NULL DEFAULT '{}',
tags text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE eval_runs (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
eval_case_id uuid NOT NULL REFERENCES eval_cases(id) ON DELETE CASCADE,
prompt_version text,
result jsonb,
passed boolean,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_evalruns_case ON eval_runs (eval_case_id, created_at DESC);
-- Fixture: população de personas para a "liga" de simulação (§17.5). NÃO é entidade de produção.
CREATE TABLE simulation_personas (
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = biblioteca global
name text NOT NULL,
archetype text NOT NULL, -- ghoster, regateador, comparador, adversarial…
segment jsonb NOT NULL DEFAULT '{}', -- renda, região, idade, letramento de canal
behavior_traits jsonb NOT NULL DEFAULT '{}', -- ghosting, typos, áudio, impaciência, injection
action_space jsonb NOT NULL DEFAULT '{}', -- distribuição de ações/turno: reply, no_reply, audio, opt_out, handoff…
response_policy jsonb NOT NULL DEFAULT '{}', -- p_ghost, latência de resposta, prob. de sumir/voltar
seeded_from text, -- ref opcional a transcrição real anonimizada
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_personas_tenant ON simulation_personas (tenant_id, archetype);
-- FK tardia (forward ref: conversations criada antes de simulation_personas).
ALTER TABLE conversations
ADD CONSTRAINT fk_conv_persona FOREIGN KEY (simulation_persona_id)
REFERENCES simulation_personas(id) ON DELETE SET NULL;

9. Observabilidade & Learning (append-only, particionadas)

Seção intitulada “9. Observabilidade & Learning (append-only, particionadas)”
CREATE TABLE timeline_events (
id uuid NOT NULL DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL,
unit_id uuid, -- denorm (nullable: evento sem conversa)
conversation_id uuid,
trace_id uuid,
event_type text NOT NULL, -- message_received, burst_sealed, memory_committed…
payload jsonb NOT NULL DEFAULT '{}', -- SEM PII (auditoria sobrevive a erasure)
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_timeline_conv ON timeline_events (conversation_id, created_at DESC);
CREATE INDEX idx_timeline_type ON timeline_events (tenant_id, event_type, created_at DESC);
CREATE TABLE runtime_logs (
id uuid NOT NULL DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL,
unit_id uuid, -- denorm (nullable)
conversation_id uuid,
trace_id uuid,
level text NOT NULL CHECK (level IN ('debug','info','warn','error')),
component text NOT NULL,
message text NOT NULL,
context jsonb NOT NULL DEFAULT '{}',
payload_ref text, -- ponteiro r2 p/ payload grande
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_logs_trace ON runtime_logs (trace_id);
CREATE INDEX idx_logs_level ON runtime_logs (tenant_id, level, created_at DESC);
CREATE TABLE llm_calls (
id uuid NOT NULL DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL,
unit_id uuid, -- denorm (nullable)
conversation_id uuid,
trace_id uuid,
response_plan_id uuid,
model text NOT NULL,
tokens_in int,
tokens_out int,
latency_ms int,
cost_usd numeric(10,6),
status text NOT NULL CHECK (status IN ('ok','error','timeout')),
prompt_ref text, -- Logfire/R2; R2 só falha/amostra
environment text NOT NULL DEFAULT 'production'
CHECK (environment IN ('production','simulation','shadow')), -- separa custo sim vs real
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_llm_conv ON llm_calls (conversation_id, created_at DESC);
CREATE INDEX idx_llm_plan ON llm_calls (response_plan_id);
CREATE TABLE outcome_signals (
id uuid NOT NULL DEFAULT uuid_generate_v7(),
tenant_id uuid NOT NULL,
unit_id uuid, -- denorm (nullable: outcome sem conversa)
conversation_id uuid,
contact_id uuid,
response_plan_id uuid,
signal_type text NOT NULL, -- reply, booking, handoff, opt_out, won, lost…
value jsonb NOT NULL DEFAULT '{}',
environment text NOT NULL DEFAULT 'production'
CHECK (environment IN ('production','simulation','shadow')), -- denorm §17.5
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_outcomes_conv ON outcome_signals (conversation_id);
CREATE INDEX idx_outcomes_type ON outcome_signals (tenant_id, signal_type, created_at DESC);
CREATE INDEX idx_outcomes_unit ON outcome_signals (tenant_id, unit_id, signal_type, created_at DESC);
  • Criar partição do mês corrente + N+1 antecipadas (job mensal, ou pg_partman).
  • Política completa (negócio vs operacional vs L3): payload-retention.md.
  • Resumo: Hot 30–90d (timeline_events, runtime_logs, llm_calls) → DROP PARTITION; negócio (messages, appointments, payments, …) permanece; Cold/Parquet (E19.T2) só quando otimização causal ligar — não gate Ato 1.
  • Erasure LGPD: a linha de contacts não pode ser deletada (FK conversations.contact_id é ON DELETE RESTRICT — confirmado no Neon). Logo erasure = anonimização in-place + purga de PII fora do Postgres:
    • Postgres: zerar PII de contacts (nome/telefone) + erased_at; deletar contact_profile_facts; anonimizar messages.body e messages.transcript (transcript carrega PII de áudio/imagem).
    • R2 (crítico — esquecido na 1ª versão): deletar objetos de channel_payloads.r2_key (payloads raw) e messages.media_ref (mídia bruta) do contato. PII também mora no R2, não só no Postgres.
    • llm_calls.prompt_ref / runtime_logs.payload_ref: purgar refs que contenham PII.
    • timeline_events permanece (sem PII) como prova de auditoria.

9b. Parâmetros de controle (capture-now, optimize-later)

Seção intitulada “9b. Parâmetros de controle (capture-now, optimize-later)”

Princípio (blueprint §4.8 + §17.4): toda decisão de estratégia do Orchestrator é (a) parametrizada na policy e (b) logada como treatment no response_plans — para que a Camada 3 (optimization, v1.1+) possa aprender o parâmetro ótimo por segmento sem retrofit de schema. Sem logar a ação tomada, não há base causal (uplift/bandit) depois.

Parâmetro = função do contexto (§17.4 Princípio 1): não é constante nem botão por situação — é política f(stage, segmento, canal, horário, intenção, janela). Contexto é entrada, não parâmetro novo. Três camadas (§17.4 Princípio 2): restrição dura (limite de canal/lei, nunca otimizada) · policy/marca (tom, disclosure, faixa de verbosidade — não auto-otimizada) · treatment (otimizável). Tom NÃO é treatment — vive na policy_profiles.rules.

ParâmetroDecisão do Orchestrator (§14)Chave na policy (policy_profiles.rules)Treatment logadoOutcome/label
Burst wait / debouncequanto esperar agregandoburst_wait {min_ms,max_ms,seal_on_silence_ms}response_plans.burst_wait_mscompletude vs. latência (reply, retrabalho)
Response delay / pacingdelay deliberado antes do enviopacing {min_delay_ms,max_delay_ms,presence}response_plans.response_delay_msnaturalidade percebida, reply rate
Follow-up timingquando reativar dormantfollowup_schedule [{attempt,delay_ms,mode}] + max_attemptsfollowup_attempt_no, dormancy_duration_ms, window_state_at_decisionreplied_after_followup, reply latency, opt_out
Send-time / horárioenviar agora vs. esperar business hourssend_window {respect_business_hours,defer_outside}scheduled_send_at vs. created_atreply rate por horário
Escalation thresholdescalar vs. responderescalation {confidence_threshold}confidence, confidence_threshold, escalatedacerto da escalação (precision/recall)
Fragmentaçãoquantas bolhasfragmentation {max_bubbles}fragment_countengajamento, leitura
Verbosidadeextensão por tipo de resposta (ack/short/normal/long)verbosity {by_intent}verbositycortar explicação vs. enrolar num “ok”
Variante de respostaqual variante enviarvariants (catálogo)variant_idconversão (bandit de conteúdo)
Memory commit gateatualizar memória?memory_gate (heurística §16.1)memory_commit + memory_commit_reasoncusto evitado vs. memória perdida

Não-treatments (não entram no otimizador): tom/linguagem e disclosure vivem na policy_profiles.rules (camada policy/marca); limite de tamanho do canal é restrição dura. Verbosidade (treatment) ≠ limite de canal (constraint).

Já capturado antes deste passo: variant_id, prompt_version, policy_profile_id, operation_mode, delivery_mode, confidence, memory_commit. Adicionados agora: plan_trigger, burst_wait_ms, response_delay_ms, verbosity, confidence_threshold, escalated, followup_attempt_no, dormancy_duration_ms, window_state_at_decision, exploration, propensity (causalidade — §17.4 Princípio 3).

Para fechar o par treatment→outcome, outcome_signals.value (jsonb) carrega, quando aplicável:

{ "reply_latency_ms": 1234, "replied_after_followup": true, "followup_attempt_no": 2 }

signal_type cobre user_replied, dormant, opt_out, booking, handoff, won, lost. Isso permite ligar cada response_plans (a ação + contexto) ao resultado observado — base para survival → uplift → bandit (§17.4).

Causalidade (§17.4 Princípios 3–4): treatment logado sozinho é confundidoresponse_plans carrega exploration + propensity p/ permitir estimativa causal (IPW/off-policy). Reward é atrasado/esparso em ciclo longo: outcome terminal (won/lost) liga ao plano por janela de atribuição (não assumir last-touch); usar outcomes intermediários (reply, avanço de stage) como proxy. Atribuição multi-touch fica como decisão de v1.1 (§19 Q).

  • Default/config: policy_profiles.rules (editável por tenant/unit/segmento — ajustável já no v1).
  • Decisão por ciclo: response_plans (o que foi de fato aplicado — o treatment).
  • Ajuste aprendido (v1.1+): optimization devolve novos valores para policy_profiles.rules (loop L3 -.-> L1, blueprint §17).

Todo o DDL foi aplicado num branch descartável Neon (projeto cr-schema-validation-throwaway, branch ddl-validation-v1) em schema isolado v1_validation. Três níveis.

33 tabelas criadas sem erro, incluindo as 6 particionadas, índice HNSW (pgvector) e a função uuid_generate_v7 em PG17. 2 furos encontrados e corrigidos:

FuroSintomaFix
uq_messages_inbound_wamid UNIQUE em tabela particionadaunique constraint on partitioned table must include all partitioning columnsíndice não-único + dedup em idempotency_keys
uuid_generate_v7() gerava 26 bytesinvalid input syntax for type uuidbase uuid_send(gen_random_uuid()) (16 bytes)

5/5 queries retornaram correto, com dados semeados:

QueryProva
Q1 resolução inboundendpoint phone_number_id + identity wa_id → conversation/unit/contact
Q2 context packúltimo summary + profile facts + messages
Q3 outbox dueentregas queued/failed com next_retry_at <= now()
Q4 outcomes por unitagregação filtrando environment='production'
Q5 follow-up learningjoin response_plans (treatment) → outcome_signals (label)

Nível 3 — invariantes (arguição, testado no banco)

Seção intitulada “Nível 3 — invariantes (arguição, testado no banco)”
InvarianteResultado
Idempotência inbound (re-inserir wamid)sem duplicata ✅
Idempotência envio (duplicar send_idempotency_key)bloqueado por UNIQUE ✅
CHECK template exige template_idbloqueado ✅
CHECK consent scope='unit' exige unit_idbloqueado ✅
Isolamento sim/prodsimulação fora do agregado de produção ✅
Erasure: FK contact = RESTRICTconfirmado → erasure é anonimização in-place
  • Gestão de partições: inserir sem partição do mês criada → erro. Criar partições antecipadas (job/pg_partman/pg_cron) ou DEFAULT partition de segurança.
  • HNSW + filtro multi-tenant: pgvector aplica o filtro tenant_id/unit_id como pós-filtro ao ANN — pode degradar recall/custo em escala. Reavaliar índice parcial por tenant ou filtragem pré-ANN quando o volume crescer.
  • FKs opcionais: messages.burst_id e conversations.simulation_persona_id sem FK (app-enforced) — aceitável; FK possível em v1.1.

Dimensões que não precisam de carga/código para serem atacadas. Feitas após a validação executável (§10b).

A) Integridade referencial — FKs ausentes (risco de órfão)

Seção intitulada “A) Integridade referencial — FKs ausentes (risco de órfão)”

Colunas que referenciam entidades sem FK no banco (app-enforced). Veredicto por caso:

ColunaAponta p/FK possível?Decisão
conversations.simulation_persona_idsimulation_personassim (ambas normais)FK adicionada (ON DELETE SET NULL, via ALTER)
outbound_deliveries.message_idmessages (particionada, PK composta)não (PK = (id,created_at))app-enforced — criada na mesma transação
response_fragments.message_idmessagesnão (idem)app-enforced
llm_calls.response_plan_idresponse_planssim, mas tabela de alto volumeapp-enforced (evita custo de FK em append-only)
outcome_signals.{response_plan_id,contact_id,conversation_id}váriassim, mas alto volumeapp-enforced (learning, append-only)
messages.burst_idmessage_burstssim, mas alto volumeapp-enforced

Invariante que a app deve garantir (vira teste no harness, §10b nível 3): nenhum message_id/response_plan_id referenciado sem a linha-pai existente no mesmo fluxo. Referências a tabelas particionadas nunca terão FK (limitação PG: PK composta com a chave de partição).

MudançaCaminho seguro
Novo valor de enumtext + CHECK → drop/add CHECK (sem ALTER TYPE, sem rewrite)
Nova coluna nullableaditiva, online
Nova coluna NOT NULLdefault + backfill, depois SET NOT NULL
Nova partiçãopré-criar (job/pg_partman); nunca depender de criação on-the-fly
Nova entidadepassa pelo checklist de adicionar entidade (processo §4b)

A escolha text + CHECK (C3) já foi feita pensando em evolução — é o que torna a maioria das mudanças de domínio online.

C) Cobertura estado → coluna (máquina de estados §13)

Seção intitulada “C) Cobertura estado → coluna (máquina de estados §13)”
  • conversations.runtime_state CHECK = exatamente os 13 estados do §13 (idle … closed) ✅
  • conversations.pacing_state CHECK = os 7 estados de silence/pacing do §13 ✅ (era texto livre — corrigido nesta arguição)
  • workflow_instances.status cobre open/won/lost/abandoned/closed ✅
  • Transições não são enforçadas no banco — são responsabilidade do Orchestrator/DO (correto; o banco guarda estado, não a máquina). Risco aceito: estado inconsistente só por bug de app, não por brecha de schema.

1 fix aplicado (CHECK em pacing_state), 1 FK adicionada (simulation_persona_id), e 6 referências a tabelas de alto volume/particionadas conscientemente deixadas app-enforced — com invariante registrado para o harness testar.


10. Decisões que ainda dependem de produto/legal (não bloqueiam o DDL)

Seção intitulada “10. Decisões que ainda dependem de produto/legal (não bloqueiam o DDL)”
RefPerguntaDefault assumido no v1
§19 Q3Conteúdo gerado no plano ou no envio?response_fragments.content nullable — suporta os dois; default = gerar no envio
§19 Q5prompt_version — semver/hash/registrysemver + git hash (ex.: v1.2.0+ab12cd)
§19 Q6outcome_signals mínimosuser_replied, booking, handoff, opt_out, won, lost, dormant
schema-dec 2.2/2.3Profile/consent compartilham entre units?profile_sharing=tenant default (configurável); consent com scope (unit e tenant)
§19 Q15Cripto PII — coluna/envelope/KMSEnvelope encryption (master key fora do Postgres); detalhar no scaffold
§19 Q17RLS no Postgres?✅ App-enforced em v1; RLS como hardening v1.1
§19 Q21dim do embeddingvector(1536) (text-embedding-3-small)
schema-dec 2.4assignees.user_id vira FK a users?✅ Coluna sem FK em v1; FK quando auth existir
§19 Q27Schedule default de follow-up no v1Heurística configurável (ex.: 4h → 24h(template) → 72h(template) → desiste)
§19 Q28Método de otimização de parâmetros de controlev1 captura treatments; v1.1+: survival → uplift → contextual bandit
§19 Q29-31Ambiente de simulação — fase, calibração sim-to-real, orçamentoSchema pronto (environment + simulation_personas); execução v1.1+

31 entidades do §11 modeladas: tenants, units, assignees, contacts, channel_endpoints, channel_identities, policy_profiles, pipeline_definitions, pipeline_stages, conversations, workflow_instances, message_bursts, messages, response_plans, response_fragments, tasks, consent_events, message_templates, channel_payloads, outbound_deliveries, conversation_summaries, contact_profile_facts, knowledge_sources, knowledge_chunks, conversation_scores, eval_cases, eval_runs, timeline_events, runtime_logs, llm_calls, outcome_signals.

Adicional (§17.5): simulation_personas — fixture da liga de simulação (não-produção).

Adicional (resiliência, §10b): idempotency_keys — dedup global não-particionada (inbound/payload).

Fora do v1 (v1.1+): bookings, resources, offerings, attachments (blueprint §11 / decisão #18).