# 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 ```sql 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 ```sql 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` ```sql 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) ```sql 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`. > Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para > minimizar re-identificación. ### `answers` ```sql 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. ``` > 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). ### `consent_versions` — textos de consentimiento versionados ```sql 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) ```sql 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`.