Files
motor-de-encuestas/DATA_MODEL.md
markosbenitez 2e6fbd7801 feat(submit): submit v3 con validación de integridad server-side [4G]
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>
2026-06-24 21:27:49 -03:00

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_sensitivity a partir de texto libre del creador: ciertos questions.type (salud/discapacidad) fuerzan is_sensitive = true a nivel pregunta y elevan la encuesta a sensible. 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 setear respondent_id debe fallar (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de CLAUDE.md.

Mapeo v3 (Etapa 4G, confirmado): qi_zone2.1 (departamento), qi_age_bucket2.4 (rango de edad). qi_sector2.6 (nivel organizacional) — v3 no tiene una pregunta equivalente a "área/gerencia" como v2 (A4); 2.6 es 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, no value crudo 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 propio instance_id. Las RPCs de dashboard que agregan datos de vínculo operan sobre filas donde instance_id != 'default'. Las RPCs existentes de v2 no se modifican: siguen usando instance_id = 'default' implícitamente (las preguntas de vínculo no estaban en el instrumento v2).

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()
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 organization solo puede leer agregados de sus propias responses, nunca filas crudas, y solo por encima de k_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_records y consent_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 con count < k_threshold (devuelve "n insuficiente" en lugar del valor).
  • Un trigger/constraint que rechaza respondent_id no nulo cuando is_anonymous = true.

Decisiones ratificadas en ejecución (Sprint 1)

  • options en preguntas rating (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 de options: array para single_choice/multiple_choice, objeto para rating.
  • required = false en 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_admin presentes pero inertes en v1: las policies RLS para org_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ía service_role. El camino org_admin queda reservado para un futuro de auto-servicio, no operado en v1.