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>
184 lines
9.2 KiB
Markdown
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.
|