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

184 lines
9.2 KiB
Markdown

# 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`.
> **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.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`
```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.
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).
### `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`.
---
## 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.