RPC transaccional SECURITY DEFINER (rpc_submit_response) como único camino de escritura. Dropea las policies WITH CHECK(true): cierra la falla donde anon podía insertar cualquier cosa. Validación server-side (7 checks) reusando el motor. Persistencia atómica con instance_id por instancia. Verificado contra Postgres real: instancias separadas, atomicidad, RLS. Riesgo residual de bypass por REST documentado (SEC-004). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9.2 KiB
DATA_MODEL.md — Contrato del modelo de datos
Este es el contrato que Claude Code implementa como migraciones de Supabase + policies de RLS en la Etapa 1. El DDL de abajo es ilustrativo del contrato (columnas, tipos, intención), no la migración final: el agente escribe las migraciones reales, constraints, índices y RLS. No agregar tablas que no estén acá sin aprobación (Nivel 3).
Convención: snake_case, claves uuid (default gen_random_uuid()), timestamps timestamptz.
UMBRAL_K se referencia como constante de proyecto (default 5).
Diagrama lógico
organizations 1──┐
│
surveys ─────────┤ (controller_role, is_anonymous, sensibilidad, seguridad)
│ │
├─ questions ─┤ (type, is_sensitive auto, conditional_rules)
│ │
responses ───────┘ (company_id, respondent_id NULLABLE, ip_hash, consent_record_id)
│
├─ answers (value JSONB)
│
consent_versions 1──< consent_records (snapshot inmutable por respuesta)
Tablas
organizations — empresas cliente
id uuid pk
name text not null
sector text -- para agrupar/benchmark sectorial (Nivel 2)
size_bucket text -- rango de tamaño, NO headcount exacto (minimización)
created_at timestamptz default now()
surveys — definición de cada estudio
id uuid pk
org_id uuid fk -> organizations(id) -- empresa para la que corre
title text not null
status text default 'draft' -- draft | live | closed
-- Eje 1: anonimato (propiedad técnica, irreversible una vez live)
is_anonymous boolean not null -- true en las 4 de v1
-- Eje 2a: sensibilidad legal (alimenta consentimiento)
data_sensitivity text not null -- 'comun' | 'sensible' | 'anonimizado'
-- Eje 2b: seguridad (alimenta RLS/cifrado) — ORTOGONAL al anterior
security_class text not null -- 'publico' | 'interno' | 'confidencial'
-- Rol de responsable
controller_role text not null default 'A' -- 'A' = nosotros | 'B' = empresa (no usado en v1)
-- Consentimiento vigente al publicar
consent_version_id uuid fk -> consent_versions(id)
-- Marca / look & feel
brand_config jsonb -- { logo_url, palette, ... }
k_threshold int not null default 5 -- umbral de k-anonimato para esta encuesta
created_at timestamptz default now()
published_at timestamptz
El agente NO calcula
data_sensitivitya partir de texto libre del creador: ciertosquestions.type(salud/discapacidad) fuerzanis_sensitive = truea nivel pregunta y elevan la encuesta asensible. La herramienta constriñe, no solo permite (ver ADR-001).
questions
id uuid pk
survey_id uuid fk -> surveys(id)
code text -- 'B1','C1','H1'... (del mockup)
type text not null -- text_short|text_long|single_choice|multiple_choice|
-- rating|nps|matrix|ranking|date|slider|yes_no|section
prompt text not null
position int not null
is_sensitive boolean not null default false -- auto-true para tipos de salud/discapacidad
max_select int -- p/ multiple_choice con límite (H1 = 3)
options jsonb -- [{value,label}, ...]
conditional_rules jsonb -- [{ if_question, op, value, action: show|hide|skip, target }]
required boolean default false
responses — una submission (anónima en v1)
id uuid pk
survey_id uuid fk -> surveys(id)
company_id uuid fk -> organizations(id) -- SE CONSERVA (habilita Nivel 2)
respondent_id uuid -- RESERVADO, NULL en encuestas anónimas
ip_hash text -- SHA-256 + salt, dedup sin identidad
consent_record_id uuid fk -> consent_records(id) not null
submitted_at timestamptz default now()
-- cuasi-identificadores denormalizados para segmentación eficiente (también viven en answers)
qi_age_bucket text -- rango, no edad exacta (minimización)
qi_sector text
qi_zone text
Regla: si
surveys.is_anonymous = true, el INSERT que intente setearrespondent_iddebe fallar (constraint/trigger). Es la barrera técnica de la regla no negociable #2 deCLAUDE.md.
Mapeo v3 (Etapa 4G, confirmado):
qi_zone←2.1(departamento),qi_age_bucket←2.4(rango de edad).qi_sector←2.6(nivel organizacional) — v3 no tiene una pregunta equivalente a "área/gerencia" como v2 (A4);2.6es el cuasi-identificador organizacional más cercano disponible en el instrumento actual, no un mapeo 1:1 con el campo de v2. Los cuasi-identificadores se guardan en bucket (rango etario, no fecha de nacimiento) para minimizar re-identificación.
answers
id uuid pk
response_id uuid fk -> responses(id)
question_id uuid fk -> questions(id)
value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc.
instance_id text not null default 'default'
-- 'default' → preguntas normales (la gran mayoría)
-- 'familiar_1', 'familiar_2', ... → instancias del mini-wizard de vínculos (4A, Sprint 4)
UNIQUE (response_id, question_id, instance_id) -- constraint: answers_response_question_instance_key
El texto libre (
text_long) no se expone al store analítico crudo: la capa de reportes lee categorías derivadas, novaluecrudo de preguntas abiertas (regla no negociable #4).Patrón
instance_id(v3.0.0): la pregunta 4.1 ("¿Tenés familiar con discapacidad/adulto mayor a cargo?") dispara el mini-wizard. Por cada familiar declarado se genera un conjunto de respuestas para las preguntas 4.2, 4.3, etc., cada una con su propioinstance_id. Las RPCs de dashboard que agregan datos de vínculo operan sobre filas dondeinstance_id != 'default'. Las RPCs existentes de v2 no se modifican: siguen usandoinstance_id = 'default'implícitamente (las preguntas de vínculo no estaban en el instrumento v2).
consent_versions — textos de consentimiento versionados
id uuid pk
version text not null -- 'v1', 'v2'...
body text not null -- texto completo presentado al titular
scopes jsonb not null -- catálogo de scopes que este texto ofrece
-- ej: ["operativo","macro_n1"] (v1 NO incluye macro_n3)
created_at timestamptz default now()
consent_records — qué consintió cada respuesta (INMUTABLE)
id uuid pk
consent_version_id uuid fk -> consent_versions(id) not null
granted_scopes jsonb not null -- subconjunto efectivamente aceptado por el titular
-- ej: {"operativo":true,"macro_n1":true}
granted_at timestamptz not null default now()
-- inmutable: sin UPDATE/DELETE; se inserta una vez junto con la response
En encuesta anónima NO hay revocación posible (no se puede ubicar la respuesta). El texto de consentimiento debe declararlo explícitamente al titular antes de responder (ADR-003).
Intención de RLS (Claude Code escribe las policies)
- Una
organizationsolo puede leer agregados de sus propiasresponses, nunca filas crudas, y solo por encima dek_threshold. No existe policy que devuelva una fila individual a una empresa. - El rol de análisis macro (interno, responsable = nosotros) puede leer filas para análisis propio, pero la capa de reportes/exportación aplica el umbral de k-anonimato y excluye texto libre crudo antes de exponer cualquier cosa.
consent_recordsyconsent_versions: solo INSERT y SELECT. Sin UPDATE/DELETE (inmutabilidad).
Vistas/funciones helper esperadas (Etapa 1)
agg_segment(survey_id, dimensions[])→ agregación que suprime automáticamente celdas concount < k_threshold(devuelve "n insuficiente" en lugar del valor).- Un trigger/constraint que rechaza
respondent_idno nulo cuandois_anonymous = true.
Decisiones ratificadas en ejecución (Sprint 1)
optionsen preguntasrating(F3, G4): se guarda como objeto{"min":1,"max":5,"min_label":"...","max_label":"..."}, NO como array. El frontend (Sprint 2) debe manejar dos formas deoptions: array parasingle_choice/multiple_choice, objeto pararating.required = falseen todas las preguntas seeded: la obligatoriedad real se define en Sprint 2 (validación del formulario). Nota: B1 estaba marcada obligatoria en el mockup; revisar al construir el formulario.- Policies de
org_adminpresentes pero inertes en v1: las policies RLS paraorg_admin(acceso multi-tenant por empresa) existen en migración 003, pero v1 NO tiene login de cliente ("nosotros presentamos a cada empresa"). El acceso real lo hace el analyst víaservice_role. El caminoorg_adminqueda reservado para un futuro de auto-servicio, no operado en v1.